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
This commit is contained in:
Jakub Zych
2026-11-04 19:36:10 +01:00
parent ae70567e63
commit 1048c01fd9
7 changed files with 655 additions and 0 deletions

129
classes/htmlgroups_test.go Normal file
View File

@@ -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 \"\"")
}
}

112
classes/indexnow_test.go Normal file
View File

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

View File

@@ -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
}

126
classes/news_test.go Normal file
View File

@@ -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:<key> and
// category:<type>:<id> (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: "<Grzyby>"}}); err != nil {
t.Fatal(err)
}
out := buf.String()
want := `<url><loc>https://example.test/a?b&amp;c</loc><news:news><news:publication><news:name>A &amp; B</news:name><news:language>pl</news:language></news:publication><news:publication_date>2026-11-04T08:00:00Z</news:publication_date><news:title>&lt;Grzyby&gt;</news:title></news:news></url>`
if !strings.Contains(out, `xmlns:news="`+NSNews+`"`) || !strings.Contains(out, want) {
t.Fatalf("news file:\n%s", out)
}
wellFormed(t, out)
}

View File

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

48
classes/types_test.go Normal file
View File

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

141
classes/writer_test.go Normal file
View File

@@ -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,
`<urlset xmlns="` + NSSitemap + `" xmlns:xhtml="` + NSXHTML + `">`,
`<loc>https://example.test/a?x=1&amp;y=&lt;2&gt;</loc><lastmod>2026-11-04T17:30:00Z</lastmod>`,
`<xhtml:link rel="alternate" hreflang="x-default" href="https://example.test/b?&#34;q&#34;"/>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("file lacks %s:\n%s", want, out)
}
}
for _, never := range []string{"<priority>", "<changefreq>"} {
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(), "<lastmod>") {
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, `<sitemapindex xmlns="`+NSSitemap+`">`) ||
!strings.Contains(out, "<sitemap><loc>https://example.test/sitemap-pages-1.xml</loc><lastmod>2026-01-02T03:04:05Z</lastmod></sitemap>") ||
!strings.Contains(out, "<sitemap><loc>https://example.test/sitemap-posts-1.xml</loc></sitemap>") {
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, "<image:image><image:loc>https://cdn.example.test/c&amp;d.webp</image:loc></image:image>") {
t.Fatalf("image file:\n%s", out)
}
if n := strings.Count(out, "<image:image>"); 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)
}
}
}