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)
+ }
+ }
+}