Migration 202610280002 converts the Phase 17 url items through models.ConvertLegacyItems: a relative path gets its leading slash, spaces and the bytes " < > \ are percent-encoded, and a malformed item no longer drops the whole definition. Each url item that cannot be copied is logged at warn level with its label, value and reason.
888 lines
38 KiB
Go
888 lines
38 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&chips?x=<1>"</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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|