- an additional address whose absolute URL a listed record already has is left out with the new reason duplicate (models.ReasonDuplicate) - the settings screen counts it under Already listed by a content type - a record left out by the filter or the type switch does not count
860 lines
37 KiB
Go
860 lines
37 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"}]`
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|