229 lines
7.4 KiB
Go
229 lines
7.4 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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í & <pos>\"\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{
|
||
|
|
`<?xml version="1.0" encoding="UTF-8"?>`,
|
||
|
|
`<rss version="2.0"`,
|
||
|
|
`<title>Můj blog</title>`,
|
||
|
|
`<title>První & <pos></title>`,
|
||
|
|
`<link>https://site.example/prvni</link>`,
|
||
|
|
`<guid>https://site.example/prvni</guid>`,
|
||
|
|
`<pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>`,
|
||
|
|
`<dc:creator>@petr@social</dc:creator>`,
|
||
|
|
`<dc:language>cs</dc:language>`,
|
||
|
|
`<language>cs</language>`,
|
||
|
|
} {
|
||
|
|
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ý</title>") && strings.Count(out, "<pubDate>") != 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, "<item>"); 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{
|
||
|
|
`<feed xmlns="http://www.w3.org/2005/Atom">`,
|
||
|
|
`<link rel="self" type="application/atom+xml" href="https://site.example/api/volumen/feed.atom"/>`,
|
||
|
|
`<id>https://site.example/</id>`,
|
||
|
|
`<updated>2026-08-18T00:00:00+00:00</updated>`,
|
||
|
|
`<title xml:lang="cs">První & <pos></title>`,
|
||
|
|
`<published>2026-08-18T00:00:00+00:00</published>`,
|
||
|
|
`<author><name>@petr@social</name></author>`,
|
||
|
|
`<summary>Tělo jedna.</summary>`,
|
||
|
|
} {
|
||
|
|
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, "<updated></updated>") {
|
||
|
|
t.Fatalf("empty updated element missing:\n%s", out)
|
||
|
|
}
|
||
|
|
if !strings.Contains(out, "<published></published>") {
|
||
|
|
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, "<strong>jedna</strong>") {
|
||
|
|
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{
|
||
|
|
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
|
||
|
|
`<loc>https://site.example/prvni</loc>`,
|
||
|
|
`<lastmod>`,
|
||
|
|
`<loc>https://site.example/druhy</loc>`,
|
||
|
|
} {
|
||
|
|
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, "<lastmod>2026-01-02</lastmod>") {
|
||
|
|
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, "<lastmod>") {
|
||
|
|
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í & <pos>"`) {
|
||
|
|
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, "<url>"); got != SitemapURLLimit {
|
||
|
|
t.Fatalf("sitemap holds %d URLs, want %d", got, SitemapURLLimit)
|
||
|
|
}
|
||
|
|
}
|