Files
sm-sitemap-plugin/sitemap_index_test.go
Jakub Zych b2b3a1d218 test: Journal-shaped records in the hreflang test
TestHreflangAlternates uses a record shaped as the Journal descriptors
describe one: every enabled locale in sort order, the untranslated one
with URL "" and the default locale's title, description and word
count; the files still carry no hreflang for that locale.
2026-11-04 23:28:49 +01:00

894 lines
39 KiB
Go

package sitemap_test
import (
"context"
"net/http"
"regexp"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/console"
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/sm-sitemap-plugin/updates"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/go-gormigrate/gormigrate/v2"
)
// bootIndex boots the fake content plugin and the sitemap on a fresh
// database with an empty content store.
func bootIndex(t *testing.T, extra ...string) *sitemapEnv {
t.Helper()
contentStore.reset(t)
return bootPlugins(t, append([]string{contentPluginID, "golem15.sitemap"}, extra...))
}
// rebuild runs the plugin's rebuild job once, as a worker would.
func (e *sitemapEnv) rebuild(t *testing.T) {
t.Helper()
for _, p := range e.plugins {
hj, ok := p.(pact.HasJobs)
if !ok || p.ID() != "golem15.sitemap" {
continue
}
for _, j := range hj.Jobs() {
if err := j.Work(context.Background(), classes.RebuildArgs{}); err != nil {
t.Fatalf("rebuild job: %v", err)
}
return
}
}
t.Fatal("the sitemap registers no rebuild job")
}
// setFileSize stores the file size setting.
func (e *sitemapEnv) setFileSize(t *testing.T, n int) {
t.Helper()
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET file_size = ? WHERE id = 1`, n).Error; err != nil {
t.Fatal(err)
}
}
// locs lists the <loc> values of a sitemap body in order.
func locs(body string) []string {
var out []string
for _, m := range regexp.MustCompile(`<loc>([^<]*)</loc>`).FindAllStringSubmatch(body, -1) {
out = append(out, m[1])
}
return out
}
// TestSitemapIndex is the tracer of D-30: a content type's published
// records appear, after a rebuild job, in an index at /sitemap.xml that
// links per-group files of file_size addresses with absolute <loc> and a
// real <lastmod>; a draft is absent, no priority or change frequency is
// written, values are XML-escaped (T-33-09) and an unknown file is a 404.
func TestSitemapIndex(t *testing.T) {
e := bootIndex(t)
e.setFileSize(t, 2)
base := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
for i, slug := range []string{"a", "b", "c", "d"} {
contentStore.put(pageType, page1(i+1, slug, base.Add(time.Duration(i)*time.Hour)))
}
contentStore.put(pageType, fakeRecord{ID: 5, Published: true, LastMod: base, Locales: []pact.ContentLocale{{Locale: "en", URL: `/pages/fish&chips?x=<1>"`, Title: `Fish & "chips" <b>`}}})
contentStore.put(pageType, fakeRecord{ID: 6, Published: false, LastMod: base, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/draft", Title: "Draft"}}})
if rec := e.get(t, "/sitemap.xml"); rec.Code != http.StatusOK || len(locs(rec.Body.String())) != 0 {
t.Fatalf("index before the first rebuild: %d %s", rec.Code, rec.Body.String())
}
e.rebuild(t)
rec := e.get(t, "/sitemap.xml")
body := rec.Body.String()
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/xml" {
t.Fatalf("GET /sitemap.xml: %d %q %s", rec.Code, rec.Header().Get("Content-Type"), body)
}
if !strings.HasPrefix(body, `<?xml version="1.0" encoding="UTF-8"?>`) || !strings.Contains(body, `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`) {
t.Fatalf("index head:\n%s", body)
}
want := []string{
"https://api.example.test/sitemap-pages-1.xml",
"https://api.example.test/sitemap-pages-2.xml",
"https://api.example.test/sitemap-pages-3.xml",
}
if got := locs(body); strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("index locs = %v, want %v\n%s", got, want, body)
}
if n := strings.Count(body, "<lastmod>"); n != 3 {
t.Fatalf("index has %d lastmod elements:\n%s", n, body)
}
if !strings.Contains(body, "<sitemap><loc>https://api.example.test/sitemap-pages-1.xml</loc><lastmod>2026-10-01T09:00:00Z</lastmod></sitemap>") {
t.Fatalf("file 1 lastmod is not the newest of its addresses:\n%s", body)
}
rec = e.get(t, "/_sitemap/files/sitemap-pages-1.xml")
file := rec.Body.String()
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/xml" || rec.Header().Get("X-Robots-Tag") != "noindex" {
t.Fatalf("file 1: %d %v %s", rec.Code, rec.Header(), file)
}
if got := locs(file); strings.Join(got, " ") != "https://api.example.test/pages/a https://api.example.test/pages/b" {
t.Fatalf("file 1 locs = %v\n%s", got, file)
}
if !strings.Contains(file, "<url><loc>https://api.example.test/pages/a</loc><lastmod>2026-10-01T08:00:00Z</lastmod></url>") {
t.Fatalf("file 1 lacks a real lastmod:\n%s", file)
}
file3 := e.get(t, "/_sitemap/files/sitemap-pages-3.xml").Body.String()
if !strings.Contains(file3, "<loc>https://api.example.test/pages/fish&amp;chips?x=&lt;1&gt;&#34;</loc>") {
t.Fatalf("file 3 does not escape the address:\n%s", file3)
}
all := body + file + e.get(t, "/_sitemap/files/sitemap-pages-2.xml").Body.String() + file3
for _, banned := range []string{"<priority>", "<changefreq>", "/pages/draft", "xml-stylesheet", "xmlns:xhtml"} {
if strings.Contains(all, banned) {
t.Errorf("the sitemap contains %q:\n%s", banned, all)
}
}
for _, path := range []string{
"/_sitemap/files/sitemap-pages-4.xml",
"/_sitemap/files/sitemap-posts-1.xml",
"/_sitemap/files/sitemap-pages-0.xml",
"/_sitemap/files/other.xml",
"/sitemap.xsl",
} {
if rec := e.get(t, path); rec.Code != http.StatusNotFound {
t.Errorf("GET %s: %d, want 404", path, rec.Code)
}
}
}
// permSitemap is the permission of the sitemap settings.
const permSitemap = "golem15.sitemap.access_definitions"
// riverJobs counts the queued jobs of kind.
func (e *sitemapEnv) riverJobs(t *testing.T, kind string) int64 {
t.Helper()
var n int64
if err := e.gdb.Raw(`SELECT COUNT(*) FROM river_job WHERE kind = ?`, kind).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
// TestSettingsSingleton drives the settings singleton (UI-SPEC C26): one
// tile opens it, the form has the Files, Content types and Additional
// addresses tabs, a save stores the values and dispatches a rebuild, the
// errors.* refusals answer 422 and leave the row as it was, a type switched
// off leaves the files with reason type and an additional address is listed
// in the pages group with its lastmod; S1 shows files.empty before the
// first build, files.building while the asked-for rebuild has not run, and
// the files with the left-out counts after it.
func TestSettingsSingleton(t *testing.T) {
e := bootIndex(t)
token := e.admin(t, permSitemap)
const base = "/golem15/sitemap/settings"
base0 := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
contentStore.put(pageType, page1(1, "a", base0))
contentStore.put(offerType, fakeRecord{ID: 1, Published: true, LastMod: base0, Locales: []pact.ContentLocale{{Locale: "en", URL: "/offers/x", Title: "Offer"}}})
// One tile, linked to the singleton; the Phase 17 tiles are gone.
rec := e.call(t, http.MethodGet, "/settings", token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"controller":"golem15.sitemap.settings"`) ||
strings.Contains(rec.Body.String(), "golem15.sitemap.definitions") || strings.Contains(rec.Body.String(), "sitemap_settings") {
t.Fatalf("settings index: %d %s", rec.Code, rec.Body.String())
}
rec = e.call(t, http.MethodGet, base+"/singleton", token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"id":1`) {
t.Fatalf("GET singleton: %d %s", rec.Code, rec.Body.String())
}
rec = e.call(t, http.MethodGet, base+"/schema/form", token, nil)
schema := rec.Body.String()
if rec.Code != http.StatusOK || !strings.Contains(schema, `"singleton":true`) {
t.Fatalf("form schema: %d %s", rec.Code, schema)
}
for _, want := range []string{`"site_url"`, `"file_size"`, `"extra_urls"`, `"type_acme_page_include"`, `"type_acme_page_slug"`, `"type_acme_page_title"`, `"type_acme_offer_include"`, "Files", "Content types", "Additional addresses"} {
if !strings.Contains(schema, want) {
t.Errorf("form schema lacks %s", want)
}
}
files := func() string {
t.Helper()
rec := e.call(t, http.MethodGet, base+"/partials/sitemap_files", token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("S1: %d %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
if s1 := files(); !strings.Contains(s1, "No files yet.") || strings.Contains(s1, "Rebuilding now.") {
t.Fatalf("S1 before the first build:\n%s", s1)
}
// Refusals leave the row as it was.
for _, c := range []struct {
name string
body map[string]any
field string
}{
{"file size 0", map[string]any{"file_size": 0}, "file_size"},
{"file size 50 001", map[string]any{"file_size": 50001}, "file_size"},
{"javascript site", map[string]any{"site_url": "javascript:alert(1)//"}, "site_url"},
{"relative site", map[string]any{"site_url": "/x"}, "site_url"},
{"bad address", map[string]any{"extra_urls": []map[string]any{{"url": "javascript:alert(1)", "lastmod": ""}}}, "extra_urls.0.url"},
{"bad lastmod", map[string]any{"extra_urls": []map[string]any{{"url": "/x", "lastmod": "yesterday"}}}, "extra_urls.0.lastmod"},
{"bad slug", map[string]any{"type_acme_page_slug": "Bad Slug"}, "type_acme_page_slug"},
{"duplicate slug", map[string]any{"type_acme_page_slug": "things", "type_acme_offer_slug": "things"}, "type_acme_offer_slug"},
{"reserved slug", map[string]any{"type_acme_page_slug": "images"}, "type_acme_page_slug"},
} {
rec := e.call(t, http.MethodPut, base+"/1", token, c.body)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) {
t.Errorf("%s: %d %s", c.name, rec.Code, rec.Body.String())
}
}
var types int64
e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_types`).Scan(&types)
if types != 0 || e.riverJobs(t, classes.RebuildKind) != 0 {
t.Fatalf("a refused save wrote %d type rows and queued %d rebuilds", types, e.riverJobs(t, classes.RebuildKind))
}
// A save stores everything and dispatches one rebuild.
rec = e.call(t, http.MethodPut, base+"/1", token, map[string]any{
"site_url": "https://www.example.test/",
"file_size": 500,
"extra_urls": []map[string]any{{"url": "/kontakt", "lastmod": "2026-09-30"}, {"url": "/o-nas", "lastmod": ""}},
"type_acme_page_include": true,
"type_acme_page_slug": "",
"type_acme_page_title": "Our pages",
"type_acme_offer_include": false,
"type_acme_offer_slug": "offers",
"type_acme_offer_title": "",
})
if rec.Code != http.StatusOK {
t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String())
}
if n := e.riverJobs(t, classes.RebuildKind); n != 1 {
t.Fatalf("the save queued %d rebuild jobs, want 1", n)
}
var row struct {
SiteURL string `gorm:"column:site_url"`
FileSize int `gorm:"column:file_size"`
}
e.gdb.Raw(`SELECT site_url, file_size FROM golem15_sitemap_settings WHERE id = 1`).Scan(&row)
if row.SiteURL != "https://www.example.test/" || row.FileSize != 500 {
t.Fatalf("stored settings = %+v", row)
}
var stored []struct {
TypeKey string `gorm:"column:type_key"`
Include bool `gorm:"column:include"`
Slug string `gorm:"column:slug"`
Titles string `gorm:"column:titles"`
}
e.gdb.Raw(`SELECT type_key, include, slug, titles::text AS titles FROM golem15_sitemap_types ORDER BY type_key`).Scan(&stored)
if len(stored) != 2 || stored[0].TypeKey != offerType || stored[0].Include || stored[0].Slug != "" ||
stored[1].TypeKey != pageType || !stored[1].Include || !strings.Contains(stored[1].Titles, "Our pages") {
t.Fatalf("type rows = %+v", stored)
}
if s1 := files(); !strings.Contains(s1, "Rebuilding now.") {
t.Fatalf("S1 while the rebuild is queued:\n%s", s1)
}
rec = e.call(t, http.MethodGet, base+"/1", token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"type_acme_page_title"`) || !strings.Contains(rec.Body.String(), "Our pages") {
t.Fatalf("GET settings: %d %s", rec.Code, rec.Body.String())
}
e.rebuild(t)
index := e.get(t, "/sitemap.xml").Body.String()
if got := locs(index); strings.Join(got, " ") != "https://www.example.test/sitemap-pages-1.xml" {
t.Fatalf("index after the save = %v\n%s", got, index)
}
pages := e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()
if got := locs(pages); strings.Join(got, " ") != "https://www.example.test/pages/a https://www.example.test/kontakt https://www.example.test/o-nas" {
t.Fatalf("pages file = %v\n%s", got, pages)
}
if !strings.Contains(pages, "<loc>https://www.example.test/kontakt</loc><lastmod>2026-09-30T00:00:00Z</lastmod>") || !strings.Contains(pages, "<url><loc>https://www.example.test/o-nas</loc></url>") {
t.Fatalf("additional addresses lack their lastmod:\n%s", pages)
}
var reason string
e.gdb.Raw(`SELECT excluded_reason FROM golem15_sitemap_urls WHERE type_key = ?`, offerType).Scan(&reason)
if reason != "type" {
t.Fatalf("the switched-off type's row has reason %q", reason)
}
if listing, ok := e.app.Lookup[contract.Listing](); !ok {
t.Fatal("no contract.Listing")
} else if ls, err := listing.Settings(t.Context()); err != nil || strings.Join(ls.TypesOff, ",") != offerType {
t.Fatalf("listing TypesOff = %v err=%v, want [%s]", ls.TypesOff, err, offerType)
}
s1 := files()
for _, want := range []string{"sitemap.xml", "sitemap-pages-1.xml", `"href":"/_sitemap/files/sitemap-pages-1.xml"`, `"href":"/sitemap.xml"`, "Left out of the sitemaps", "Content type turned off", "https://www.example.test/sitemap.xml"} {
if !strings.Contains(s1, want) {
t.Errorf("S1 lacks %q:\n%s", want, s1)
}
}
if strings.Contains(s1, "Rebuilding now.") || strings.Contains(s1, "No files yet.") {
t.Errorf("S1 after the rebuild:\n%s", s1)
}
// Without the permission every route answers 403.
other := e.admin(t)
for _, c := range []struct{ method, path string }{
{http.MethodGet, base + "/singleton"},
{http.MethodPut, base + "/1"},
{http.MethodGet, base + "/partials/sitemap_files"},
} {
if rec := e.call(t, c.method, c.path, other, map[string]any{}); rec.Code != http.StatusForbidden {
t.Errorf("%s %s without permission: %d %s", c.method, c.path, rec.Code, rec.Body.String())
}
}
}
// TestMigrateDefinitions pins the D-32 data move of migration
// 202610280002: the url items of the served definition (theme default)
// become the additional addresses as given (no lastmod, unsafe and repeated
// ones skipped), frontend_url becomes site_url and frontend_default_locale
// default_locale; reference items are dropped, the definitions table and
// the Phase 17 columns keep their data, and a rollback removes only the
// copied values.
func TestMigrateDefinitions(t *testing.T) {
sqlDB := freshDB(t)
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
all := updates.All()
opts := &gormigrate.Options{TableName: "sitemap_migrations", IDColumnName: "id", IDColumnSize: 255, UseTransaction: true}
upTo := 0
for i, m := range all {
if m.ID == "202610110002_create_golem15_sitemap_settings" {
upTo = i + 1
}
}
if upTo == 0 {
t.Fatal("the Phase 17 settings migration is missing")
}
if err := gormigrate.New(gdb, opts, all[:upTo]).Migrate(); err != nil {
t.Fatal(err)
}
items := `[{"type":"url","url":"/","changefreq":"always","priority":"1.0"},` +
`{"type":"acme-post","reference":"7","nesting":true},` +
`{"type":"url","url":"/o-nas"},` +
`{"type":"url","url":"javascript:alert(1)"},` +
`{"type":"url","url":"/"},` +
`{"type":"url","url":"https://www.example.test/kontakt"},` +
`{"type":"url","url":"kontakt-pl"},` +
`{"type":"url","url":"/blog/zażółć gęślą"},` +
`{"type":"url","url":"https://www.example.test/x?a=1&b=2"},` +
`{"type":"url","url":{"bad":true}}]`
for _, stmt := range []struct {
sql string
args []any
}{
{`INSERT INTO golem15_sitemap_definitions (theme, data) VALUES (?, ?)`, []any{"default", items}},
{`INSERT INTO golem15_sitemap_definitions (theme, data) VALUES (?, ?)`, []any{"other", `[{"type":"url","url":"/other"}]`}},
{`UPDATE golem15_sitemap_settings SET frontend_url = ?, frontend_default_locale = ?, priority_blog = ? WHERE id = 1`, []any{"https://www.example.test/", "pl", "0.7"}},
} {
if err := gdb.Exec(stmt.sql, stmt.args...).Error; err != nil {
t.Fatal(err)
}
}
if err := gormigrate.New(gdb, opts, all).Migrate(); err != nil {
t.Fatal(err)
}
s, err := classes.LoadSettings(gdb)
if err != nil {
t.Fatal(err)
}
// L2 WR-04: one malformed item no longer drops the definition, a
// relative path gets its slash and a space is percent-encoded; the &
// URL and javascript: are dropped (and logged).
if got := extraURLs(s.Extra()); got != "/ /o-nas https://www.example.test/kontakt /kontakt-pl /blog/zażółć%20gęślą" {
t.Fatalf("extra_urls = %q", got)
}
for _, e := range s.Extra() {
if e.LastMod != "" {
t.Fatalf("a copied address has a lastmod: %+v", e)
}
}
if models.Text(s.SiteURL) != "https://www.example.test/" || models.Text(s.DefaultLocale) != "pl" || s.EffectiveFileSize() != 1000 {
t.Fatalf("settings = %+v", s)
}
var defs int64
gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_definitions`).Scan(&defs)
var data string
gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE theme = 'default'`).Scan(&data)
if defs != 2 || data != items {
t.Fatalf("the definitions table changed: %d rows, data %s", defs, data)
}
if err := gormigrate.New(gdb, opts, all).RollbackTo("202610110002_create_golem15_sitemap_settings"); err != nil {
t.Fatal(err)
}
var old struct {
FrontendURL string `gorm:"column:frontend_url"`
Locale string `gorm:"column:frontend_default_locale"`
Blog string `gorm:"column:priority_blog"`
}
gdb.Raw(`SELECT frontend_url, frontend_default_locale, priority_blog FROM golem15_sitemap_settings WHERE id = 1`).Scan(&old)
if old.FrontendURL != "https://www.example.test/" || old.Locale != "pl" || old.Blog != "0.7" {
t.Fatalf("the rollback changed the Phase 17 settings: %+v", old)
}
gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_definitions`).Scan(&defs)
if defs != 2 {
t.Fatalf("the rollback changed the definitions: %d rows", defs)
}
var columns int64
gdb.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'golem15_sitemap_settings' AND column_name IN ('site_url', 'extra_urls')`).Scan(&columns)
if columns != 0 {
t.Fatalf("the rollback left %d copied columns", columns)
}
}
// fileNos lists the file numbers of the listed rows of typ in record order.
func (e *sitemapEnv) fileNos(t *testing.T, typ string) []int {
t.Helper()
var out []int
if err := e.gdb.Raw(`SELECT file_no FROM golem15_sitemap_urls WHERE type_key = ? AND excluded_reason = '' ORDER BY record_id::int, locale`, typ).Scan(&out).Error; err != nil {
t.Fatal(err)
}
return out
}
func intsEq(a []int, b ...int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestStableNumbering pins D-30's numbering invariant over real rebuilds:
// 5 addresses at size 2 fill files 1,1,2,2,3; a deleted record leaves a
// hole in file 1 and files 2 and 3 keep their addresses; two new records go
// to file 3, then a new file 4; only a file size change renumbers.
func TestStableNumbering(t *testing.T) {
e := bootIndex(t)
e.setFileSize(t, 2)
base := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
for i := 1; i <= 5; i++ {
contentStore.put(pageType, page1(i, string(rune('a'+i-1)), base))
}
e.rebuild(t)
if got := e.fileNos(t, pageType); !intsEq(got, 1, 1, 2, 2, 3) {
t.Fatalf("first build: %v", got)
}
file2 := e.get(t, "/_sitemap/files/sitemap-pages-2.xml").Body.String()
contentStore.remove(pageType, 2)
e.rebuild(t)
if got := e.fileNos(t, pageType); !intsEq(got, 1, 2, 2, 3) {
t.Fatalf("after a delete: %v", got)
}
if got := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()); len(got) != 1 || got[0] != "https://api.example.test/pages/a" {
t.Fatalf("file 1 after the delete: %v", got)
}
if again := e.get(t, "/_sitemap/files/sitemap-pages-2.xml").Body.String(); again != file2 {
t.Fatalf("file 2 changed after a delete in file 1:\n%s\n%s", file2, again)
}
var reason string
e.gdb.Raw(`SELECT excluded_reason FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = '2'`, pageType).Scan(&reason)
if reason != "deleted" {
t.Fatalf("the deleted record's row has reason %q", reason)
}
contentStore.put(pageType, page1(6, "f", base))
contentStore.put(pageType, page1(7, "g", base))
e.rebuild(t)
if got := e.fileNos(t, pageType); !intsEq(got, 1, 2, 2, 3, 3, 4) {
t.Fatalf("after two appends: %v", got)
}
index := locs(e.get(t, "/sitemap.xml").Body.String())
if len(index) != 4 || index[3] != "https://api.example.test/sitemap-pages-4.xml" {
t.Fatalf("index after appends: %v", index)
}
e.setFileSize(t, 3)
e.rebuild(t)
if got := e.fileNos(t, pageType); !intsEq(got, 1, 1, 1, 2, 2, 2) {
t.Fatalf("after a file size change: %v", got)
}
if index := locs(e.get(t, "/sitemap.xml").Body.String()); len(index) != 2 {
t.Fatalf("index after renumbering: %v", index)
}
}
// lastmodOf reads the lastmod of one row.
func (e *sitemapEnv) lastmodOf(t *testing.T, typ, id, locale string) time.Time {
t.Helper()
var mod *time.Time
if err := e.gdb.Raw(`SELECT lastmod FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = ? AND locale = ?`, typ, id, locale).Scan(&mod).Error; err != nil {
t.Fatal(err)
}
if mod == nil {
t.Fatalf("%s %s %s has no lastmod", typ, id, locale)
}
return mod.UTC()
}
// saved fires a RecordSaved of a fake page.
func (e *sitemapEnv) saved(t *testing.T, id string, changes ...pact.RecordChange) {
t.Helper()
if err := e.app.Events.Fire(context.Background(), &pact.RecordSaved{Model: pageModel, ID: id, Changes: changes}); err != nil {
t.Fatal(err)
}
}
// TestLastmod pins event-driven lastmod (D-30, RESEARCH Pitfall 4): a save
// whose changes touch a real attribute moves the lastmod of that record's
// rows (only the changed locale's row for a translated change); a no-op
// save or one that changes only ignored attributes does not; a rebuild
// never moves lastmod, even when the record's own LastMod moved; a deleted
// record leaves the files at once.
func TestLastmod(t *testing.T) {
e := bootIndex(t)
base := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
contentStore.put(pageType, page1(1, "a", base))
contentStore.put(pageType, fakeRecord{ID: 2, Published: true, LastMod: base, Locales: []pact.ContentLocale{
{Locale: "en", URL: "/pages/b", Title: "B"},
{Locale: "pl", URL: "/pl/strony/b", Title: "B pl"},
}})
e.rebuild(t)
if got := e.lastmodOf(t, pageType, "1", "en"); !got.Equal(base) {
t.Fatalf("first lastmod = %v, want the record's %v", got, base)
}
e.saved(t, "1")
e.saved(t, "1", pact.RecordChange{Attribute: "updated_at"}, pact.RecordChange{Attribute: "views", Old: 1, New: 2})
if got := e.lastmodOf(t, pageType, "1", "en"); !got.Equal(base) {
t.Fatalf("a no-op or ignored save moved lastmod to %v", got)
}
before := time.Now().Add(-time.Second)
e.saved(t, "1", pact.RecordChange{Attribute: "title", Old: "A", New: "A2"}, pact.RecordChange{Attribute: "updated_at"})
if got := e.lastmodOf(t, pageType, "1", "en"); got.Before(before) {
t.Fatalf("a title change left lastmod at %v", got)
}
e.saved(t, "2", pact.RecordChange{Attribute: "title", Locale: "pl", Old: "B pl", New: "B pl 2"})
if en, pl := e.lastmodOf(t, pageType, "2", "en"), e.lastmodOf(t, pageType, "2", "pl"); !en.Equal(base) || pl.Before(before) {
t.Fatalf("a pl change: en %v, pl %v", en, pl)
}
moved := e.lastmodOf(t, pageType, "1", "en")
later := page1(1, "a", base.Add(48*time.Hour))
contentStore.put(pageType, later)
e.rebuild(t)
if got := e.lastmodOf(t, pageType, "1", "en"); !got.Equal(moved) {
t.Fatalf("a rebuild moved lastmod from %v to %v", moved, got)
}
if err := e.app.Events.Fire(context.Background(), &pact.RecordDeleted{Model: pageModel, ID: "1"}); err != nil {
t.Fatal(err)
}
if got := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()); strings.Contains(strings.Join(got, " "), "/pages/a") {
t.Fatalf("a deleted record is still listed: %v", got)
}
// A record of a model no content type declares is ignored.
if err := e.app.Events.Fire(context.Background(), &pact.RecordSaved{Model: "other_model", ID: "2", Changes: []pact.RecordChange{{Attribute: "title"}}}); err != nil {
t.Fatal(err)
}
}
// TestDebouncedRebuild pins P33-12: record events within the delay queue
// exactly one rebuild job, scheduled after golem15.sitemap.rebuild_delay_seconds;
// once the job ran, the next event queues another; a no-op save queues none.
func TestDebouncedRebuild(t *testing.T) {
e := bootIndex(t)
contentStore.put(pageType, page1(1, "a", time.Now()))
e.saved(t, "1")
if n := e.riverJobs(t, classes.RebuildKind); n != 0 {
t.Fatalf("a no-op save queued %d rebuilds", n)
}
start := time.Now()
for range 3 {
e.saved(t, "1", pact.RecordChange{Attribute: "title"})
}
if n := e.riverJobs(t, classes.RebuildKind); n != 1 {
t.Fatalf("three saves queued %d rebuilds, want 1", n)
}
var at time.Time
if err := e.gdb.Raw(`SELECT scheduled_at FROM river_job WHERE kind = ?`, classes.RebuildKind).Scan(&at).Error; err != nil {
t.Fatal(err)
}
if d := at.Sub(start); d < 55*time.Second || d > 70*time.Second {
t.Fatalf("the rebuild is scheduled %v after the first save, want about 60s", d)
}
e.rebuild(t)
e.saved(t, "1", pact.RecordChange{Attribute: "title"})
if n := e.riverJobs(t, classes.RebuildKind); n != 2 {
t.Fatalf("a save after the rebuild: %d jobs, want 2", n)
}
if err := e.app.Events.Fire(context.Background(), &pact.RecordDeleted{Model: pageModel, ID: "1"}); err != nil {
t.Fatal(err)
}
if n := e.riverJobs(t, classes.RebuildKind); n != 2 {
t.Fatalf("a delete within the delay queued another rebuild: %d", n)
}
}
// TestRebuildCommand runs sitemap:rebuild through the console: without
// --queue it rebuilds in the process and reports the counts, with --queue
// it dispatches the rebuild job.
func TestRebuildCommand(t *testing.T) {
e := bootIndex(t)
contentStore.put(pageType, page1(1, "a", time.Now()))
run := func(args ...string) (int, string) {
var buf strings.Builder
root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.Rebuild(e.app)}, strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatal(err)
}
return bonfire.Execute(context.Background(), root, args), buf.String()
}
if code, out := run("sitemap:rebuild"); code != 0 || !strings.Contains(out, "Rebuilt the sitemaps: 1 addresses in 1 files (1 added, 0 changed, 0 removed).") {
t.Fatalf("sitemap:rebuild: %d\n%s", code, out)
}
if got := locs(e.get(t, "/sitemap.xml").Body.String()); len(got) != 1 {
t.Fatalf("index after the command: %v", got)
}
if code, out := run("sitemap:rebuild", "--queue"); code != 0 || !strings.Contains(out, "Queued the sitemap rebuild") || e.riverJobs(t, classes.RebuildKind) != 1 {
t.Fatalf("sitemap:rebuild --queue: %d\n%s", code, out)
}
}
// TestHreflangAlternates pins D-55 to D-57: every address of a record with
// more than one translated locale carries xhtml:link alternates for each
// locale, itself included, plus x-default on the default locale's address;
// a locale without an address gets none, a single-locale record none.
func TestHreflangAlternates(t *testing.T) {
e := bootIndex(t)
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET default_locale = 'pl' WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
// G3: a Journal-shaped record (P33-64): every enabled locale in sort
// order, the untranslated de with URL "" and the default locale's
// title, description and word count.
words := 120
contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{
{Locale: "pl", URL: "/strony/a", Title: "Strona A", Description: "O stronie", Words: &words},
{Locale: "en", URL: "/en/pages/a", Title: "Page A", Description: "About the page", Words: &words},
{Locale: "de", URL: "", Title: "Strona A", Description: "O stronie", Words: &words},
}})
contentStore.put(pageType, fakeRecord{ID: 2, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/strony/b"}}})
e.rebuild(t)
body := e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()
if !strings.Contains(body, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">`) {
t.Fatalf("the xhtml namespace is not declared:\n%s", body)
}
alts := `<xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/strony/a"/>` +
`<xhtml:link rel="alternate" hreflang="en" href="https://api.example.test/en/pages/a"/>` +
`<xhtml:link rel="alternate" hreflang="x-default" href="https://api.example.test/strony/a"/>`
for _, loc := range []string{"https://api.example.test/en/pages/a", "https://api.example.test/strony/a"} {
if !strings.Contains(body, "<url><loc>"+loc+"</loc><lastmod>2026-10-01T08:00:00Z</lastmod>"+alts+"</url>") {
t.Errorf("%s lacks the alternates:\n%s", loc, body)
}
}
if strings.Contains(body, `hreflang="de"`) {
t.Errorf("a locale without an address got an alternate:\n%s", body)
}
if !strings.Contains(body, "<url><loc>https://api.example.test/strony/b</loc><lastmod>2026-10-01T08:00:00Z</lastmod></url>") {
t.Errorf("a single-locale record got alternates:\n%s", body)
}
// Without a default locale setting, x-default is the record's first
// locale; a locale that leaves the record drops out of the alternates.
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET default_locale = NULL WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{
{Locale: "en", URL: "/en/pages/a"}, {Locale: "pl", URL: "/strony/a"}, {Locale: "de", URL: "/de/seiten/a"},
}})
e.rebuild(t)
body = e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()
if !strings.Contains(body, `hreflang="x-default" href="https://api.example.test/en/pages/a"`) || strings.Count(body, `hreflang="de"`) != 3 {
t.Fatalf("x-default without a default locale, or the new locale:\n%s", body)
}
contentStore.put(pageType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "en", URL: "/en/pages/a"}}})
e.rebuild(t)
if body = e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String(); strings.Contains(body, "xhtml:link") {
t.Fatalf("a record down to one locale keeps alternates:\n%s", body)
}
}
// TestFilterExcludes pins D-95 and D-24 with a published Filter: an
// excluded address leaves the files and is counted under its reason, an
// address the Filter moves lands in sitemap-offers-1.xml, every batch is
// one page of one type (or the additional addresses), and the Filter's
// group titles reach Listing.Settings.
func TestFilterExcludes(t *testing.T) {
theFilter.mu.Lock()
theFilter.batches = nil
theFilter.mu.Unlock()
e := bootIndex(t, filterPluginID)
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
contentStore.put(pageType, page1(1, "a", mod))
contentStore.put(pageType, page1(2, "noindex-me", mod))
contentStore.put(pageType, page1(3, "promo", mod))
contentStore.put(offerType, fakeRecord{ID: 1, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "en", URL: "/offers/x"}}})
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = '[{"url":"/kontakt"},{"url":"/noindex-extra"}]' WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
e.rebuild(t)
pages := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String())
if strings.Join(pages, " ") != "https://api.example.test/pages/a https://api.example.test/kontakt" {
t.Fatalf("pages file = %v", pages)
}
offers := locs(e.get(t, "/_sitemap/files/sitemap-offers-1.xml").Body.String())
if strings.Join(offers, " ") != "https://api.example.test/pages/promo https://api.example.test/offers/x" {
t.Fatalf("offers file = %v", offers)
}
var n int64
e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_urls WHERE excluded_reason = 'noindex'`).Scan(&n)
if n != 2 {
t.Fatalf("%d rows left out as noindex, want 2", n)
}
theFilter.mu.Lock()
batches := theFilter.batches
theFilter.mu.Unlock()
if len(batches) != 3 || len(batches[0]) != 3 || batches[0][0].TypeKey != pageType || batches[0][0].RecordID != "1" || batches[0][0].Locale != "en" ||
batches[1][0].TypeKey != offerType || batches[2][0].TypeKey != "extra" || batches[2][0].URL != "/kontakt" {
t.Fatalf("batches = %+v", batches)
}
listing, ok := e.app.Lookup[contract.Listing]()
if !ok {
t.Fatal("the sitemap publishes no contract.Listing")
}
settings, err := listing.Settings(context.Background())
if err != nil {
t.Fatal(err)
}
if settings.SiteURL != "https://api.example.test" || settings.IndexURL != "https://api.example.test/sitemap.xml" || len(settings.Groups) != 2 {
t.Fatalf("listing settings = %+v", settings)
}
offersGroup := settings.Groups[0]
if offersGroup.Key != "offers" || offersGroup.Slug != "offers" || offersGroup.Count != 2 || offersGroup.Titles["pl"] != "Oferty" || settings.Groups[1].Key != "pages" || settings.Groups[1].Count != 2 {
t.Fatalf("listing groups = %+v", settings.Groups)
}
s1 := e.call(t, http.MethodGet, "/golem15/sitemap/settings/partials/sitemap_files", e.admin(t, permSitemap), nil).Body.String()
if !strings.Contains(s1, `{"text":"noindex"}`) || !strings.Contains(s1, `{"text":"2"}`) {
t.Fatalf("S1 lacks the noindex count:\n%s", s1)
}
}
// TestNoFilter pins D-95 without a Filter: everything published is listed.
func TestNoFilter(t *testing.T) {
e := bootIndex(t)
if _, ok := e.app.Lookup[contract.Filter](); ok {
t.Fatal("a Filter is published without the filter plugin")
}
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
contentStore.put(pageType, page1(1, "noindex-me", mod))
contentStore.put(pageType, page1(2, "promo", mod))
e.rebuild(t)
if got := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String()); len(got) != 2 {
t.Fatalf("without a Filter: %v", got)
}
if rec := e.get(t, "/_sitemap/files/sitemap-offers-1.xml"); rec.Code != http.StatusNotFound {
t.Fatalf("an offers file without a Filter: %d", rec.Code)
}
}
// TestExtraDuplicates pins the de-duplication of the additional addresses
// against the content records: an additional address a listed record has
// (as a path or as the same absolute URL) is in the files once, counted as
// left out with the reason duplicate, and listed again once the record is
// gone.
func TestExtraDuplicates(t *testing.T) {
e := bootIndex(t)
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
contentStore.put(pageType, page1(1, "a", mod))
contentStore.put(pageType, page1(2, "b", mod))
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = '[{"url":"/pages/a"},{"url":"https://api.example.test/pages/b"},{"url":"/kontakt"}]' WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
e.rebuild(t)
pages := locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String())
if strings.Join(pages, " ") != "https://api.example.test/pages/a https://api.example.test/pages/b https://api.example.test/kontakt" {
t.Fatalf("pages file = %v, want each address once", pages)
}
var n int64
e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_urls WHERE type_key = 'extra' AND excluded_reason = ?`, models.ReasonDuplicate).Scan(&n)
if n != 2 {
t.Fatalf("%d additional addresses left out as duplicate, want 2", n)
}
s1 := e.call(t, http.MethodGet, "/golem15/sitemap/settings/partials/sitemap_files", e.admin(t, permSitemap), nil).Body.String()
if !strings.Contains(s1, "Already listed by a content type") {
t.Fatalf("S1 lacks the duplicate reason:\n%s", s1)
}
contentStore.remove(pageType, 2)
e.rebuild(t)
pages = locs(e.get(t, "/_sitemap/files/sitemap-pages-1.xml").Body.String())
// The additional address's row has been in the table since the first
// rebuild, so it keeps its place.
if strings.Join(pages, " ") != "https://api.example.test/pages/a https://api.example.test/pages/b https://api.example.test/kontakt" {
t.Fatalf("pages file after the record is gone = %v, want the additional address listed again", pages)
}
e.gdb.Raw(`SELECT COUNT(*) FROM golem15_sitemap_urls WHERE type_key = 'extra' AND excluded_reason = ?`, models.ReasonDuplicate).Scan(&n)
if n != 1 {
t.Fatalf("%d additional addresses left out as duplicate after the record is gone, want 1", n)
}
}
// TestListing pins contract.Listing.Entries: a group's listed addresses in
// pages of Limit with Next and Total, absolute URLs with title and
// lastmod, the locale filter keeping addresses without a locale, and an
// unknown group empty.
func TestListing(t *testing.T) {
e := bootIndex(t)
mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
for i := 1; i <= 5; i++ {
contentStore.put(pageType, page1(i, string(rune('a'+i-1)), mod))
}
contentStore.put(pageType, fakeRecord{ID: 6, Published: true, LastMod: mod, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/strony/f", Title: "F"}}})
if err := e.gdb.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = '[{"url":"/kontakt"}]' WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
e.rebuild(t)
listing, ok := e.app.Lookup[contract.Listing]()
if !ok {
t.Fatal("no contract.Listing")
}
ctx := context.Background()
var all []string
after := ""
for range 10 {
page, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", After: after, Limit: 3})
if err != nil {
t.Fatal(err)
}
if page.Total != 7 {
t.Fatalf("Total = %d", page.Total)
}
for _, en := range page.Entries {
all = append(all, en.URL)
}
if page.Next == "" {
break
}
after = page.Next
}
if len(all) != 7 || all[0] != "https://api.example.test/pages/a" || all[6] != "https://api.example.test/kontakt" {
t.Fatalf("paged entries = %v", all)
}
first, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", Limit: 1})
if err != nil || len(first.Entries) != 1 || first.Entries[0].Title != "Page a" || first.Entries[0].TypeKey != pageType || first.Entries[0].RecordID != "1" ||
first.Entries[0].LastMod == nil || !first.Entries[0].LastMod.Equal(mod) || first.Next == "" {
t.Fatalf("first entry = %+v %v", first, err)
}
pl, err := listing.Entries(ctx, contract.EntriesQuery{Group: "pages", Locale: "pl"})
if err != nil || pl.Total != 2 || pl.Entries[0].URL != "https://api.example.test/strony/f" || pl.Entries[1].URL != "https://api.example.test/kontakt" {
t.Fatalf("pl entries = %+v %v", pl, err)
}
none, err := listing.Entries(ctx, contract.EntriesQuery{Group: "nope"})
if err != nil || none.Total != 0 || len(none.Entries) != 0 || none.Next != "" {
t.Fatalf("unknown group = %+v %v", none, err)
}
// contract.Rebuilder (P33-59) queues the debounced rebuild a record
// event queues, once per debounce window.
rb, ok := e.app.Lookup[contract.Rebuilder]()
if !ok {
t.Fatal("no contract.Rebuilder")
}
for range 2 {
if err := rb.RequestRebuild(ctx); err != nil {
t.Fatal(err)
}
}
var pending *time.Time
if err := e.gdb.Raw(`SELECT rebuild_pending_until FROM golem15_sitemap_settings WHERE id = 1`).Scan(&pending).Error; err != nil {
t.Fatal(err)
}
if pending == nil {
t.Fatal("RequestRebuild set no rebuild_pending_until")
}
if n := e.riverJobs(t, classes.RebuildKind); n != 1 {
t.Fatalf("two requests queued %d rebuilds, want 1", n)
}
}