From 1048c01fd913173866efd678d0e4bfc28b52dade Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 19:36:10 +0100 Subject: [PATCH] test: unit tests for the writer, News, IndexNow, HTML groups, slugs, lastmod and slots - XML escaping and namespaces, no priority, the image cap and file names - News window and cap, sources and languages, the publication name - IndexNow batches of 10 000, answer outcomes, keys and the host filter - HTML sitemap group order, tree order and paging - default slugs, the effective type setting, lastmod config, slot edges --- classes/htmlgroups_test.go | 129 +++++++++++++++++++++++++++++++++ classes/indexnow_test.go | 112 +++++++++++++++++++++++++++++ classes/lastmod_test.go | 64 +++++++++++++++++ classes/news_test.go | 126 +++++++++++++++++++++++++++++++++ classes/slots_test.go | 35 +++++++++ classes/types_test.go | 48 +++++++++++++ classes/writer_test.go | 141 +++++++++++++++++++++++++++++++++++++ 7 files changed, 655 insertions(+) create mode 100644 classes/htmlgroups_test.go create mode 100644 classes/indexnow_test.go create mode 100644 classes/news_test.go create mode 100644 classes/types_test.go create mode 100644 classes/writer_test.go diff --git a/classes/htmlgroups_test.go b/classes/htmlgroups_test.go new file mode 100644 index 0000000..33fc03f --- /dev/null +++ b/classes/htmlgroups_test.go @@ -0,0 +1,129 @@ +package classes + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "git.golem15.com/golem15/sm-sitemap-plugin/models" +) + +// TestHTMLGroupOrder pins UI-SPEC L3's order of the named groups. +func TestHTMLGroupOrder(t *testing.T) { + if got := strings.Join(htmlGroupOrder, " "); got != "pages offers services categories posts tags authors" { + t.Fatalf("htmlGroupOrder = %s", got) + } +} + +// TestToEntries pins the links of rows: absolute on the site, the path as +// the title when there is none, the first parent kept, and a row that +// cannot be made absolute skipped. +func TestToEntries(t *testing.T) { + refs, _ := json.Marshal([]models.Ref{{Type: "cat", ID: "1"}, {Type: "cat", ID: "9"}}) + got := toEntries([]htmlRow{ + {TypeKey: "cat", RecordID: "2", URL: "/kategoria/b", Title: " Bee ", ParentRefs: string(refs)}, + {TypeKey: "cat", RecordID: "3", URL: "/kategoria/c"}, + {TypeKey: "cat", RecordID: "4", URL: "javascript:alert(1)", Title: "x"}, + {TypeKey: "cat", RecordID: "5", URL: "https://example.test/abs", Title: "Abs", ParentRefs: "[]"}, + }, "https://example.test") + if len(got) != 3 { + t.Fatalf("entries = %+v, want 3 (the javascript: row skipped)", got) + } + if got[0].Title != "Bee" || got[0].URL != "https://example.test/kategoria/b" || got[0].parent == nil || *got[0].parent != (models.Ref{Type: "cat", ID: "1"}) || got[0].node != (models.Ref{Type: "cat", ID: "2"}) { + t.Fatalf("entry 0 = %+v", got[0]) + } + if got[1].Title != "/kategoria/c" || got[1].parent != nil || got[2].parent != nil || got[2].URL != "https://example.test/abs" { + t.Fatalf("entries 1 and 2 = %+v %+v", got[1], got[2]) + } +} + +func treeEntry(id, title string, parent string) entry { + e := entry{HTMLEntry: HTMLEntry{Title: title, URL: "/" + id}, node: models.Ref{Type: "cat", ID: id}} + if parent != "" { + e.parent = &models.Ref{Type: "cat", ID: parent} + } + return e +} + +// TestTreeOrder pins the tree order: depth first with siblings in the +// input order (alphabetical from the query), the parent indices into the +// flat result, an entry whose parent is not in the group a root, and a +// cycle broken into a root. +func TestTreeOrder(t *testing.T) { + in := []entry{ + treeEntry("1", "A", ""), + treeEntry("2", "A1", "1"), + treeEntry("3", "B", ""), + treeEntry("4", "A2", "1"), + treeEntry("5", "A1a", "2"), + treeEntry("6", "Orphan", "99"), + treeEntry("7", "Self", "7"), + treeEntry("8", "Cy1", "9"), + treeEntry("9", "Cy2", "8"), + } + flat, parents := treeOrder(in) + var titles []string + for _, e := range flat { + titles = append(titles, e.Title) + } + if got := strings.Join(titles, " "); got != "A A1 A1a A2 B Orphan Self Cy1 Cy2" { + t.Fatalf("order = %s", got) + } + if fmt.Sprint(parents) != "[-1 0 1 0 -1 -1 -1 -1 7]" { + t.Fatalf("parents = %v", parents) + } + if len(flat) != len(in) { + t.Fatalf("%d entries out of %d", len(flat), len(in)) + } +} + +// TestNestPaging pins the nesting of one page of the tree: a child whose +// parent is on the same page nests under it (children in order), a child +// whose parent is on an earlier page is a top-level link of its page, and +// pages cover every entry once. +func TestNestPaging(t *testing.T) { + flat, parents := treeOrder([]entry{ + treeEntry("1", "A", ""), + treeEntry("2", "A1", "1"), + treeEntry("3", "A2", "1"), + treeEntry("4", "A2x", "3"), + treeEntry("5", "B", ""), + }) + whole := nest(flat, parents, 0, len(flat)) + if len(whole) != 2 || whole[0].Title != "A" || len(whole[0].Children) != 2 || whole[0].Children[1].Title != "A2" || + len(whole[0].Children[1].Children) != 1 || whole[0].Children[1].Children[0].Title != "A2x" || whole[1].Title != "B" || whole[1].Children != nil { + t.Fatalf("whole tree = %+v", whole) + } + first := nest(flat, parents, 0, 2) + second := nest(flat, parents, 2, 4) + third := nest(flat, parents, 4, 5) + if len(first) != 1 || first[0].Title != "A" || len(first[0].Children) != 1 { + t.Fatalf("page 1 = %+v", first) + } + if len(second) != 1 || second[0].Title != "A2" || len(second[0].Children) != 1 || second[0].Children[0].Title != "A2x" { + t.Fatalf("page 2 = %+v, want A2 as a top-level link with its child", second) + } + if len(third) != 1 || third[0].Title != "B" { + t.Fatalf("page 3 = %+v", third) + } + if got := nest(flat, parents, 5, 5); got == nil || len(got) != 0 { + t.Fatalf("an empty page = %v, want []", got) + } +} + +// TestPickTitle pins the heading per locale: the locale's, else the +// default locale's, else "". +func TestPickTitle(t *testing.T) { + titles := map[string]string{"en": "Posts", "pl": " ", "de": "Beiträge"} + for _, tc := range []struct{ locale, def, want string }{ + {"de", "en", "Beiträge"}, {"pl", "en", "Posts"}, {"fr", "en", "Posts"}, {"fr", "it", ""}, {"en", "", "Posts"}, + } { + if got := pickTitle(titles, tc.locale, tc.def); got != tc.want { + t.Errorf("pickTitle(%s, %s) = %q, want %q", tc.locale, tc.def, got, tc.want) + } + } + if pickTitle(nil, "en", "en") != "" { + t.Fatal("no titles must give \"\"") + } +} diff --git a/classes/indexnow_test.go b/classes/indexnow_test.go new file mode 100644 index 0000000..4be0a05 --- /dev/null +++ b/classes/indexnow_test.go @@ -0,0 +1,112 @@ +package classes + +import ( + "fmt" + "net/http" + "regexp" + "strings" + "testing" + + "git.golem15.com/golem15/sm-sitemap-plugin/models" +) + +// TestIndexNowBatches pins the requests of at most 10 000 addresses, in +// order, with nothing for no addresses. +func TestIndexNowBatches(t *testing.T) { + if MaxIndexNowURLs != 10000 { + t.Fatalf("MaxIndexNowURLs = %d", MaxIndexNowURLs) + } + urls := func(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = fmt.Sprintf("https://example.test/%d", i) + } + return out + } + for _, tc := range []struct { + n int + want []int + }{{0, nil}, {1, []int{1}}, {10000, []int{10000}}, {10001, []int{10000, 1}}, {25000, []int{10000, 10000, 5000}}} { + got := indexNowBatches(urls(tc.n)) + var sizes []int + next := 0 + for _, b := range got { + sizes = append(sizes, len(b)) + if len(b) > 0 && b[0] != fmt.Sprintf("https://example.test/%d", next) { + t.Fatalf("%d addresses: a batch starts at %s, want %d", tc.n, b[0], next) + } + next += len(b) + } + if fmt.Sprint(sizes) != fmt.Sprint(tc.want) { + t.Errorf("%d addresses: batches %v, want %v", tc.n, sizes, tc.want) + } + } +} + +// TestIndexNowOutcome pins the answer mapping: 200 and 202 sent, 429 and +// 5xx retried, 400, 403 and 422 (and any other status) refused. +func TestIndexNowOutcome(t *testing.T) { + for status, want := range map[int]int{ + http.StatusOK: indexNowSent, http.StatusAccepted: indexNowSent, + http.StatusTooManyRequests: indexNowRetry, 500: indexNowRetry, 502: indexNowRetry, 503: indexNowRetry, + http.StatusBadRequest: indexNowRefused, http.StatusForbidden: indexNowRefused, http.StatusUnprocessableEntity: indexNowRefused, + http.StatusNoContent: indexNowRefused, http.StatusNotFound: indexNowRefused, + } { + if got := indexNowOutcome(status); got != want { + t.Errorf("status %d: outcome %d, want %d", status, got, want) + } + } +} + +// TestIndexNowKeys pins the key format (8 to 128 letters, digits and +// hyphens), the generated key (32 lowercase hex characters, a new one each +// time), the key file name and the active key only while IndexNow is on. +func TestIndexNowKeys(t *testing.T) { + for k, want := range map[string]bool{ + "abcd1234": true, "ABCD-1234-efgh": true, strings.Repeat("a", 128): true, + "abc1234": false, strings.Repeat("a", 129): false, "abcd_1234": false, "abcd 1234": false, "../abcd1234": false, "": false, + } { + if ValidKey(k) != want { + t.Errorf("ValidKey(%q) = %v, want %v", k, !want, want) + } + } + a, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + b, _ := GenerateKey() + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(a) || a == b || !ValidKey(a) { + t.Fatalf("GenerateKey = %q, %q", a, b) + } + if KeyFileName(a) != a+".txt" || KeyFileName("short") != "" || KeyFileName("x/../y12345") != "" { + t.Fatal("KeyFileName is wrong") + } + key := " " + a + " " + bad := "bad key" + if ActiveKey(models.Settings{IndexNowEnabled: true, IndexNowKey: &key}) != a || + ActiveKey(models.Settings{IndexNowEnabled: false, IndexNowKey: &key}) != "" || + ActiveKey(models.Settings{IndexNowEnabled: true, IndexNowKey: &bad}) != "" || + ActiveKey(models.Settings{IndexNowEnabled: true}) != "" { + t.Fatal("ActiveKey is wrong") + } +} + +// TestSiteURLs pins the host filter: only http(s) addresses on the site's +// host (case-insensitive, any port or scheme), each once, in order; +// nothing without a site address. +func TestSiteURLs(t *testing.T) { + got := SiteURLs("https://Example.test", []string{ + "https://example.test/a", "https://EXAMPLE.test/b", "http://example.test/c", "https://example.test:8443/d", + "https://example.test/a", "https://www.example.test/e", "https://other.test/f", "/g", "ftp://example.test/h", "", + }) + want := "https://example.test/a https://EXAMPLE.test/b http://example.test/c https://example.test:8443/d" + if strings.Join(got, " ") != want { + t.Fatalf("SiteURLs = %v, want %s", got, want) + } + if SiteURLs("", []string{"https://example.test/a"}) != nil || SiteURLs("/relative", []string{"/a"}) != nil { + t.Fatal("without a site address nothing is kept") + } + if got := SiteURLs("https://example.test", nil); got == nil || len(got) != 0 { + t.Fatalf("no addresses: %v, want an empty list", got) + } +} diff --git a/classes/lastmod_test.go b/classes/lastmod_test.go index 03329c9..5e47944 100644 --- a/classes/lastmod_test.go +++ b/classes/lastmod_test.go @@ -1,8 +1,12 @@ package classes import ( + "slices" + "strings" "testing" + "time" + "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -36,3 +40,63 @@ func TestRealChanges(t *testing.T) { } } } + +// TestLastmodIgnore pins golem15.sitemap.lastmod_ignore: the default list +// when unset, a YAML list or a comma-separated string replacing it (names +// trimmed, blanks dropped), and an empty list ignoring nothing. +func TestLastmodIgnore(t *testing.T) { + keys := func(m map[string]bool) string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + slices.Sort(out) + return strings.Join(out, ",") + } + if got := keys(LastmodIgnore(nil)); got != "created_at,sort_order,updated_at,views" { + t.Fatalf("default = %s", got) + } + for _, tc := range []struct { + value any + want string + }{ + {[]any{"updated_at", " hits ", ""}, "hits,updated_at"}, + {[]string{"a", "b"}, "a,b"}, + {" views , rank,,", "rank,views"}, + {[]any{}, ""}, + } { + cfg := testConfig(t) + if err := cfg.Set(LastmodIgnoreConfigKey, tc.value); err != nil { + t.Fatal(err) + } + if got := keys(LastmodIgnore(cfg)); got != tc.want { + t.Errorf("LastmodIgnore(%#v) = %s, want %s", tc.value, got, tc.want) + } + } +} + +// TestRebuildDelay pins golem15.sitemap.rebuild_delay_seconds: 60 s when +// unset, the value in seconds, and 0 for 0 or a negative value. +func TestRebuildDelay(t *testing.T) { + if RebuildDelay(nil) != time.Minute || RebuildDelay(testConfig(t)) != time.Minute { + t.Fatal("default delay is not 60 s") + } + for value, want := range map[int]time.Duration{5: 5 * time.Second, 0: 0, -3: 0} { + cfg := testConfig(t) + if err := cfg.Set(RebuildDelayConfigKey, value); err != nil { + t.Fatal(err) + } + if got := RebuildDelay(cfg); got != want { + t.Errorf("RebuildDelay(%d) = %s, want %s", value, got, want) + } + } +} + +func testConfig(t *testing.T) *compass.Config { + t.Helper() + cfg, err := compass.Open(compass.Options{Dir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return cfg +} diff --git a/classes/news_test.go b/classes/news_test.go new file mode 100644 index 0000000..a925457 --- /dev/null +++ b/classes/news_test.go @@ -0,0 +1,126 @@ +package classes + +import ( + "bytes" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" +) + +// TestNewsLimits pins the News sitemap's window and cap (RESEARCH Pitfall +// 9): 48 hours and 1000 articles. The 47 h and 49 h boundaries on the +// database are TestNewsSitemap's. +func TestNewsLimits(t *testing.T) { + if NewsWindow != 48*time.Hour || MaxNewsEntries != 1000 || NewsFile != "sitemap-news.xml" { + t.Fatalf("window %s, cap %d, file %s", NewsWindow, MaxNewsEntries, NewsFile) + } + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + age time.Duration + want bool + }{{0, true}, {47 * time.Hour, true}, {48 * time.Hour, true}, {48*time.Hour + time.Second, false}, {49 * time.Hour, false}} { + published := now.Add(-tc.age) + in := !published.Before(now.Add(-NewsWindow)) && !published.After(now) + if in != tc.want { + t.Errorf("published %s before now: in window %v, want %v", tc.age, in, tc.want) + } + } +} + +// TestParseNewsSources pins the news_sources values: type: and +// category:: (the last colon splits, so a type key may hold +// one); other forms and empty parts are ignored. +func TestParseNewsSources(t *testing.T) { + s := parseNewsSources([]string{"type:blog-post", "category:blog-category:7", "category:a:b:9", "type:", "category:x", "category::1", "category:x:", "other:1", ""}) + if len(s.types) != 1 || !s.types["blog-post"] { + t.Fatalf("types = %v", s.types) + } + if len(s.parents) != 2 || !s.parents[models.Ref{Type: "blog-category", ID: "7"}] || !s.parents[models.Ref{Type: "a:b", ID: "9"}] { + t.Fatalf("parents = %v", s.parents) + } + if s.empty() || !parseNewsSources(nil).empty() || !parseNewsSources([]string{"bogus"}).empty() { + t.Fatal("empty() is wrong") + } +} + +// TestNewsSourcesMatch pins P33-15: no source lists every article; else an +// article is listed by its type or by one of its parents. +func TestNewsSourcesMatch(t *testing.T) { + cat := models.Ref{Type: "blog-category", ID: "7"} + other := models.Ref{Type: "blog-category", ID: "8"} + all := parseNewsSources(nil) + byType := parseNewsSources([]string{"type:news"}) + byCat := parseNewsSources([]string{"category:blog-category:7"}) + for _, tc := range []struct { + name string + s newsSources + typ string + parents []models.Ref + want bool + }{ + {"none listed", all, "blog-post", nil, true}, + {"type listed", byType, "news", nil, true}, + {"other type", byType, "blog-post", []models.Ref{cat}, false}, + {"parent listed", byCat, "blog-post", []models.Ref{other, cat}, true}, + {"other parent", byCat, "blog-post", []models.Ref{other}, false}, + {"no parents", byCat, "blog-post", nil, false}, + } { + if got := tc.s.matches(tc.typ, tc.parents); got != tc.want { + t.Errorf("%s: matches = %v, want %v", tc.name, got, tc.want) + } + } +} + +// TestNewsLanguage pins the language codes Google reads. +func TestNewsLanguage(t *testing.T) { + for in, want := range map[string]string{ + "pl": "pl", "pl-PL": "pl", "pl_PL": "pl", " EN ": "en", "en-GB": "en", + "zh": "zh-cn", "zh-Hans": "zh-cn", "zh-CN": "zh-cn", "zh-TW": "zh-tw", "zh_tw": "zh-tw", "zh-Hant": "zh-tw", "zh-Hant-HK": "zh-tw", + "": "", + } { + if got := NewsLanguage(in); got != want { + t.Errorf("NewsLanguage(%q) = %q, want %q", in, got, want) + } + } +} + +// TestNewsPublication pins the publication name: the setting, else +// app.name, else nothing. +func TestNewsPublication(t *testing.T) { + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_APP__NAME=Acme Daily"}}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + named := " The Acme Times " + if got := NewsPublication(app, models.Settings{NewsPublication: &named}); got != "The Acme Times" { + t.Fatalf("with the setting: %q", got) + } + blank := " " + if got := NewsPublication(app, models.Settings{NewsPublication: &blank}); got != "Acme Daily" { + t.Fatalf("with a blank setting: %q, want app.name", got) + } + if got := NewsPublication(nil, models.Settings{}); got != "" { + t.Fatalf("without an app: %q", got) + } +} + +// TestWriteNewsURLSet pins the News file: the news namespace, every +// element in order, escaping and the UTC publication date. +func TestWriteNewsURLSet(t *testing.T) { + at := time.Date(2026, 11, 4, 9, 0, 0, 0, time.FixedZone("CET", 3600)) + var buf bytes.Buffer + if err := WriteNewsURLSet(&buf, []NewsEntry{{Loc: "https://example.test/a?b&c", Publication: "A & B", Language: "pl", Published: at, Title: ""}}); err != nil { + t.Fatal(err) + } + out := buf.String() + want := `https://example.test/a?b&cA & Bpl2026-11-04T08:00:00Z<Grzyby>` + if !strings.Contains(out, `xmlns:news="`+NSNews+`"`) || !strings.Contains(out, want) { + t.Fatalf("news file:\n%s", out) + } + wellFormed(t, out) +} diff --git a/classes/slots_test.go b/classes/slots_test.go index 912226f..36c3b75 100644 --- a/classes/slots_test.go +++ b/classes/slots_test.go @@ -70,3 +70,38 @@ func TestAssignSlots(t *testing.T) { t.Fatalf("returning row moved to %d", *rows[1].FileNo) } } + +// TestAssignSlotsEdges pins the edges: a size below 1 counts as 1, the +// oldest unnumbered row is appended first (first seen, then id), an empty +// table changes nothing, and a renumber with nothing listed clears every +// number. +func TestAssignSlotsEdges(t *testing.T) { + if AssignSlots(nil, 10, true) { + t.Fatal("an empty table changed") + } + t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + rows := []*Slot{ + {ID: 3, Grp: "pages", FirstSeen: t0, Listed: true}, + {ID: 1, Grp: "pages", FirstSeen: t0.Add(time.Minute), Listed: true}, + {ID: 2, Grp: "pages", FirstSeen: t0, Listed: true}, + } + if !AssignSlots(rows, 0, false) { + t.Fatal("numbering reported no change") + } + // Size 0 is 1: one row per file, oldest first (id 2 before id 3 at the + // same time, then id 1). + if *rows[2].FileNo != 1 || *rows[0].FileNo != 2 || *rows[1].FileNo != 3 { + t.Fatalf("files: id3=%d id1=%d id2=%d", *rows[0].FileNo, *rows[1].FileNo, *rows[2].FileNo) + } + for _, r := range rows { + r.Listed = false + } + if !AssignSlots(rows, 5, true) { + t.Fatal("renumber reported no change") + } + for _, r := range rows { + if r.FileNo != nil { + t.Fatalf("row %d kept file %d after a renumber with nothing listed", r.ID, *r.FileNo) + } + } +} diff --git a/classes/types_test.go b/classes/types_test.go new file mode 100644 index 0000000..285b562 --- /dev/null +++ b/classes/types_test.go @@ -0,0 +1,48 @@ +package classes + +import ( + "testing" + + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestDefaultSlug pins the default group slug: the last segment of the +// key, lower-cased, letters and digits only, in plural form. +func TestDefaultSlug(t *testing.T) { + for key, want := range map[string]string{ + "journal-post": "posts", "journal-category": "categories", "seo-page": "pages", "journal-tag": "tags", + "journal-author": "authors", "acme.news": "news", "acme_day": "days", "shop-boxes": "boxes", "Acme-Story": "stories", + "x-y": "ys", "a-key": "keys", "": "pages", "acme-": "pages", "acme-żółw": "ws", + } { + if got := DefaultSlug(key); got != want { + t.Errorf("DefaultSlug(%q) = %q, want %q", key, got, want) + } + } +} + +// TestEffectiveType pins a content type's setting: listed under its +// default slug without a saved row; the saved include, slug and headings +// otherwise, with an invalid saved slug falling back to the default. +func TestEffectiveType(t *testing.T) { + typ := pact.MenuItemType{Key: "journal-post", Label: "Posts"} + if got := EffectiveType(typ, nil); got.Key != "journal-post" || got.Label != "Posts" || !got.Include || got.Slug != "posts" || got.Titles != nil { + t.Fatalf("without a row = %+v", got) + } + rows := map[string]models.TypeSetting{"journal-post": {TypeKey: "journal-post", Include: false, Slug: "articles", Titles: lagoon.Jsonable[map[string]string]{Data: map[string]string{"pl": "Artykuły"}, Valid: true}}} + if got := EffectiveType(typ, rows); got.Include || got.Slug != "articles" || got.Titles["pl"] != "Artykuły" { + t.Fatalf("with a row = %+v", got) + } + rows["journal-post"] = models.TypeSetting{TypeKey: "journal-post", Include: true, Slug: "Bad Slug"} + if got := EffectiveType(typ, rows); !got.Include || got.Slug != "posts" || got.Titles != nil { + t.Fatalf("with an invalid slug = %+v", got) + } +} + +// TestReservedSlugs pins the slugs the image and News files own. +func TestReservedSlugs(t *testing.T) { + if !ReservedSlugs["images"] || !ReservedSlugs["news"] || len(ReservedSlugs) != 2 || ReservedSlugs["pages"] { + t.Fatalf("ReservedSlugs = %v", ReservedSlugs) + } +} diff --git a/classes/writer_test.go b/classes/writer_test.go new file mode 100644 index 0000000..144ad76 --- /dev/null +++ b/classes/writer_test.go @@ -0,0 +1,141 @@ +package classes + +import ( + "bytes" + "encoding/xml" + "strings" + "testing" + "time" +) + +// TestWriteURLSet pins a sitemap file: the sitemap namespace, the xhtml +// namespace only when an entry has alternates, escaped locs and +// attributes, lastmod in UTC RFC 3339 and never a priority or changefreq, +// and well-formed XML. +func TestWriteURLSet(t *testing.T) { + at := time.Date(2026, 11, 4, 18, 30, 0, 0, time.FixedZone("CET", 3600)) + var buf bytes.Buffer + err := WriteURLSet(&buf, []FileEntry{ + {Loc: "https://example.test/a?x=1&y=<2>", LastMod: &at}, + {Loc: "https://example.test/b", Alternates: []FileAlternate{{Hreflang: "en", Href: "https://example.test/b"}, {Hreflang: "x-default", Href: `https://example.test/b?"q"`}}}, + }) + if err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{ + xml.Header, + ``, + `https://example.test/a?x=1&y=<2>2026-11-04T17:30:00Z`, + ``, + } { + if !strings.Contains(out, want) { + t.Fatalf("file lacks %s:\n%s", want, out) + } + } + for _, never := range []string{"", ""} { + if strings.Contains(out, never) { + t.Fatalf("file has %s:\n%s", never, out) + } + } + wellFormed(t, out) + + buf.Reset() + if err := WriteURLSet(&buf, []FileEntry{{Loc: "https://example.test/"}}); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), "xmlns:xhtml") || strings.Contains(buf.String(), "") { + t.Fatalf("no alternates and no lastmod, yet:\n%s", buf.String()) + } + wellFormed(t, buf.String()) +} + +// TestWriteIndex pins the index: one sitemap per file, lastmod only when +// known. +func TestWriteIndex(t *testing.T) { + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + var buf bytes.Buffer + if err := WriteIndex(&buf, []IndexFile{{Loc: "https://example.test/sitemap-pages-1.xml", LastMod: &at}, {Loc: "https://example.test/sitemap-posts-1.xml"}}); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, ``) || + !strings.Contains(out, "https://example.test/sitemap-pages-1.xml2026-01-02T03:04:05Z") || + !strings.Contains(out, "https://example.test/sitemap-posts-1.xml") { + t.Fatalf("index:\n%s", out) + } + wellFormed(t, out) +} + +// TestWriteImageURLSet pins the image file: the image namespace, only +// image:image and image:loc, escaping, and the MaxImagesPerURL cap. +func TestWriteImageURLSet(t *testing.T) { + many := make([]string, MaxImagesPerURL+5) + for i := range many { + many[i] = "https://cdn.example.test/i.jpg?n=" + strings.Repeat("1", i%3+1) + } + var buf bytes.Buffer + if err := WriteImageURLSet(&buf, []ImageEntry{{Loc: "https://example.test/a", Images: []string{"https://cdn.example.test/c&d.webp"}}, {Loc: "https://example.test/b", Images: many}}); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, `xmlns:image="`+NSImage+`"`) || !strings.Contains(out, "https://cdn.example.test/c&d.webp") { + t.Fatalf("image file:\n%s", out) + } + if n := strings.Count(out, ""); n != 1+MaxImagesPerURL { + t.Fatalf("%d images, want 1 + the %d cap", n, MaxImagesPerURL) + } + for _, never := range []string{"image:caption", "image:title", "image:license"} { + if strings.Contains(out, never) { + t.Fatalf("image file has %s", never) + } + } + wellFormed(t, out) +} + +// TestFileNames pins FileName and ParseFileName as inverses and the names +// ParseFileName refuses. +func TestFileNamesRoundTrip(t *testing.T) { + for _, tc := range []struct { + grp string + n int + }{{"pages", 1}, {"posts", 12}, {"news-2", 999999999}, {"a", 3}} { + name := FileName(tc.grp, tc.n) + grp, n, ok := ParseFileName(name) + if !ok || grp != tc.grp || n != tc.n { + t.Errorf("ParseFileName(%q) = %q %d %v", name, grp, n, ok) + } + } + for _, bad := range []string{"sitemap.xml", "sitemap-pages-0.xml", "sitemap-pages-01.xml", "sitemap-Pages-1.xml", "sitemap--1.xml", "sitemap-pages-1.xml.gz", "sitemap-pages-1234567890.xml", "../sitemap-pages-1.xml", "sitemap-pages-1"} { + if _, _, ok := ParseFileName(bad); ok { + t.Errorf("ParseFileName(%q) accepted", bad) + } + } +} + +// TestValidSlug pins the group slug rule. +func TestValidSlug(t *testing.T) { + for s, want := range map[string]bool{ + "pages": true, "news-2": true, "0abc": true, strings.Repeat("a", 64): true, + "": false, "-pages": false, "pages-": false, "Pages": false, "pa ges": false, "pa_ges": false, "żółw": false, strings.Repeat("a", 65): false, + } { + if got := ValidSlug(s); got != want { + t.Errorf("ValidSlug(%q) = %v, want %v", s, got, want) + } + } +} + +// wellFormed parses out as XML to its end. +func wellFormed(t *testing.T, out string) { + t.Helper() + d := xml.NewDecoder(strings.NewReader(out)) + for { + _, err := d.Token() + if err != nil { + if err.Error() == "EOF" { + return + } + t.Fatalf("not well-formed: %v\n%s", err, out) + } + } +}