// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package feeds import ( "fmt" "os" "path/filepath" "strings" "testing" "sourcedock.dev/petrbalvin/volumen/internal/config" "sourcedock.dev/petrbalvin/volumen/internal/frontmatter" "sourcedock.dev/petrbalvin/volumen/internal/post" ) var testSite = config.Site{ Title: "Můj blog", Description: "Testovací blog", BaseURL: "https://site.example", Language: "cs", Author: "Petr", } func samplePosts(t *testing.T) []*post.Post { t.Helper() p1 := parsePost(t, "+++\ntitle = \"První & \"\nslug = \"prvni\"\ndate = 2026-08-18\nlang = \"cs\"\ntags = [\"go\"]\nfediverse_creator = \"@petr@social\"\n+++\nTělo **jedna**.\n") p2 := parsePost(t, "+++\ntitle = \"Druhý\"\nslug = \"druhy\"\n+++\nTělo dva.\n") p1.Path = filepath.Join(t.TempDir(), "prvni.md") return []*post.Post{p1, p2} } func parsePost(t *testing.T, content string) *post.Post { t.Helper() p, err := post.Parse(content) if err != nil { t.Fatalf("parse: %v", err) } return p } func TestRenderRSSFeed(t *testing.T) { posts := samplePosts(t) out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml") for _, want := range []string{ ``, `Můj blog`, `První & <pos>`, `https://site.example/prvni`, `https://site.example/prvni`, `Tue, 18 Aug 2026 00:00:00 GMT`, `@petr@social`, `cs`, `cs`, } { if !strings.Contains(out, want) { t.Fatalf("rss missing %q:\n%s", want, out) } } // Post without a date has no pubDate. if strings.Contains(out, "druhý") && strings.Count(out, "") != 1 { t.Fatalf("unexpected pubDate count:\n%s", out) } } func TestRenderRSSFeedItemLimit(t *testing.T) { var posts []*post.Post for range FeedItemLimit + 5 { p := parsePost(t, "+++\nslug = \"p\"\ntitle = \"t\"\n+++\nx\n") posts = append(posts, p) } out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml") if got := strings.Count(out, ""); got != FeedItemLimit { t.Fatalf("items = %d, want %d", got, FeedItemLimit) } } func TestRenderAtomFeed(t *testing.T) { posts := samplePosts(t) out := RenderAtomFeed(posts, testSite, "https://site.example", "/api/volumen/feed.atom") for _, want := range []string{ ``, ``, `https://site.example/`, `2026-08-18T00:00:00+00:00`, `První & <pos>`, `2026-08-18T00:00:00+00:00`, `@petr@social`, `Tělo jedna.`, } { if !strings.Contains(out, want) { t.Fatalf("atom missing %q:\n%s", want, out) } } } func TestRenderAtomFeedWithoutDates(t *testing.T) { p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n") out := RenderAtomFeed([]*post.Post{p}, testSite, "https://site.example", "/api/volumen/feed.atom") // An entry without a date emits empty updated and published // elements, which consumers rely on. if !strings.Contains(out, "") { t.Fatalf("empty updated element missing:\n%s", out) } if !strings.Contains(out, "") { t.Fatalf("empty published element missing:\n%s", out) } } func TestRenderJSONFeed(t *testing.T) { posts := samplePosts(t) out := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json") if out.Version != "https://jsonfeed.org/version/1.1" { t.Fatalf("version = %v", out.Version) } if out.Title != "Můj blog" || out.Language != "cs" { t.Fatalf("feed = %+v", out) } if out.FeedURL != "https://site.example/api/volumen/feed.json" { t.Fatalf("feed_url = %v", out.FeedURL) } if len(out.Items) != 2 { t.Fatalf("items = %v", out.Items) } first := out.Items[0] if first.ID != "https://site.example/prvni" { t.Fatalf("item = %+v", first) } if !strings.Contains(first.ContentHTML, "jedna") { t.Fatalf("content_html = %v", first.ContentHTML) } if first.DatePublished != "2026-08-18" { t.Fatalf("date_published = %v", first.DatePublished) } if len(first.Authors) != 1 || first.Authors[0].Name != "@petr@social" { t.Fatalf("authors = %v", first.Authors) } if second := out.Items[1]; second.DatePublished != "" { t.Fatalf("date_published should be empty: %+v", second) } } func TestRenderJSONFeedSiteAuthorFallback(t *testing.T) { p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n") site := config.Site{Title: "T", FediverseCreator: "@site@host"} out := RenderJSONFeed([]*post.Post{p}, site, "https://site.example", "/api/volumen/feed.json") if len(out.Items) != 1 || out.Items[0].Authors[0].Name != "@site@host" { t.Fatalf("authors = %v", out.Items) } } func TestRenderSitemap(t *testing.T) { posts := samplePosts(t) out := RenderSitemap(posts, "https://site.example") for _, want := range []string{ ``, `https://site.example/prvni`, ``, `https://site.example/druhy`, } { if !strings.Contains(out, want) { t.Fatalf("sitemap missing %q:\n%s", want, out) } } } func TestSitemapLastmodFallsBackToDate(t *testing.T) { p := parsePost(t, "+++\nslug = \"x\"\ndate = 2026-01-02\n+++\nb\n") out := RenderSitemap([]*post.Post{p}, "https://site.example") if !strings.Contains(out, "2026-01-02") { t.Fatalf("sitemap = %s", out) } } func TestUnparseableDatePassesThrough(t *testing.T) { if got := rfc822Date("not-a-date"); got != "not-a-date" { t.Fatalf("rfc822Date = %q", got) } if got := rfc3339Date("not-a-date"); got != "not-a-date" { t.Fatalf("rfc3339Date = %q", got) } } func TestSitemapLastmodFromRealFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "x.md") if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { t.Fatalf("write: %v", err) } p := parsePost(t, "+++\nslug = \"x\"\n+++\nb\n") p.Path = path out := RenderSitemap([]*post.Post{p}, "https://site.example") if !strings.Contains(out, "") { t.Fatalf("sitemap = %s", out) } } func TestMarshalJSONFeedNoHTMLEscaping(t *testing.T) { posts := samplePosts(t) feed := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json") out, err := MarshalJSONFeed(feed) if err != nil { t.Fatalf("MarshalJSONFeed: %v", err) } if strings.Contains(string(out), `\u0026`) || strings.Contains(string(out), `\u003c`) { t.Fatalf("HTML escaping leaked in: %s", out) } if !strings.Contains(string(out), `"title":"První & "`) { t.Fatalf("literal characters missing: %s", out) } } // One sitemap document carries at most SitemapURLLimit URLs, the // sitemaps.org protocol ceiling. func TestRenderSitemapCapsURLs(t *testing.T) { posts := make([]*post.Post, SitemapURLLimit+250) for i := range posts { meta := frontmatter.NewMeta() meta.Set("slug", fmt.Sprintf("post-%d", i)) posts[i] = post.New(meta, "body") } out := RenderSitemap(posts, "https://site.example") if got := strings.Count(out, ""); got != SitemapURLLimit { t.Fatalf("sitemap holds %d URLs, want %d", got, SitemapURLLimit) } }