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 values of a sitemap body in order. func locs(body string) []string { var out []string for _, m := range regexp.MustCompile(`([^<]*)`).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 and a // real ; 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" `}}}) 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, ``) || !strings.Contains(body, ``) { 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, ""); n != 3 { t.Fatalf("index has %d lastmod elements:\n%s", n, body) } if !strings.Contains(body, "https://api.example.test/sitemap-pages-1.xml2026-10-01T09:00:00Z") { 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, "https://api.example.test/pages/a2026-10-01T08:00:00Z") { 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, "https://api.example.test/pages/fish&chips?x=<1>"") { 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{"", "", "/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, "https://www.example.test/kontakt2026-09-30T00:00:00Z") || !strings.Contains(pages, "https://www.example.test/o-nas") { 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) 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, ``) { t.Fatalf("the xhtml namespace is not declared:\n%s", body) } alts := `` + `` + `` for _, loc := range []string{"https://api.example.test/en/pages/a", "https://api.example.test/strony/a"} { if !strings.Contains(body, ""+loc+"2026-10-01T08:00:00Z"+alts+"") { 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, "https://api.example.test/strony/b2026-10-01T08:00:00Z") { 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) } }