Files
sm-sitemap-plugin/sitemap_index_test.go
Jakub Zych 9e5c393709 feat: the listing names the content types switched off
contract.ListingSettings.TypesOff lists the signpost keys of the content
types switched off in the sitemap settings, so another plugin (the SEO
tab's sitemap status) can tell an address left out with ReasonType
without reading the sitemap's tables. Additive.
2026-11-04 13:35:17 +01:00

818 lines
35 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"}]`
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)
}
if got := extraURLs(s.Extra()); got != "/ /o-nas https://www.example.test/kontakt" {
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)
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: ""},
}})
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="en" href="https://api.example.test/en/pages/a"/>` +
`<xhtml:link rel="alternate" hreflang="pl" href="https://api.example.test/strony/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)
}
}
// 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)
}
}