package sitemap_test import ( "context" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "sync" "testing" "time" "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/contract" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/fetchguard" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" ) // journalPluginID is a fake blog plugin with an article type (posts at // /blog/) and a collection type (categories at /kategoria/) // whose records live in contentStore. It knows nothing about the sitemap. const journalPluginID = "acme.journal" // The fake blog content types. const ( postType = "acme-post" categoryType = "acme-category" ) // journalPlugin is acme.journal. type journalPlugin struct{} func (journalPlugin) ID() string { return journalPluginID } func (journalPlugin) Requires() []string { return nil } func (journalPlugin) Register(*backpack.App) error { return nil } func (journalPlugin) Boot(*backpack.App) error { return nil } func (journalPlugin) MenuItemTypes() []pact.MenuItemType { typ := func(key, label, kind, pattern string) pact.MenuItemType { return pact.MenuItemType{ Key: key, Label: label, DefaultPattern: pattern, Placeholders: []string{"slug"}, Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { return nil, nil }, Content: &pact.ContentDescriptor{ Model: key, Kind: kind, Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) { return contentStore.page(key, in), nil }, Describe: func(context.Context, pact.ContentDescribe) (*pact.ContentRecord, error) { return nil, nil }, }, } } return []pact.MenuItemType{ typ(postType, "Posts", pact.ContentKindArticle, "/blog/:slug"), typ(categoryType, "Categories", pact.ContentKindCollection, "/kategoria/:slug"), } } var _ pact.HasMenuItemTypes = journalPlugin{} func init() { party.Register(journalPlugin{}) } // bootJournal boots the fake blog plugin and the sitemap on a fresh // database with an empty content store; overrides set config keys. func bootJournal(t *testing.T, overrides ...map[string]any) *sitemapEnv { t.Helper() contentStore.reset(t) return bootPlugins(t, []string{journalPluginID, "golem15.sitemap"}, overrides...) } // post is a published post in one locale at /blog/, first published // at published, with images. func post(id int, slug, locale string, published time.Time, images ...string) fakeRecord { l := pact.ContentLocale{Locale: locale, URL: "/blog/" + slug, Title: "Post " + slug} for i, img := range images { if i == 0 { l.Image = img } l.Images = append(l.Images, pact.ContentImage{URL: img}) } return fakeRecord{ID: id, Published: true, LastMod: published, PublishedAt: &published, Locales: []pact.ContentLocale{l}} } // exec runs one SQL statement on the test database. func (e *sitemapEnv) exec(t *testing.T, sql string, args ...any) { t.Helper() if err := e.gdb.Exec(sql, args...).Error; err != nil { t.Fatal(err) } } // imageLocs lists the image:loc values of an image sitemap body. func imageLocs(body string) []string { var out []string for _, m := range regexp.MustCompile(`([^<]*)`).FindAllStringSubmatch(body, -1) { out = append(out, m[1]) } return out } // TestImageSitemap is the tracer of D-31's image sitemap: a post's // featured and body images appear, after a rebuild, in sitemap-images-1.xml // linked from the index: the page's and one image:image/image:loc per // image (featured first, repeated and unsafe addresses dropped, paths made // absolute), only those two tags, at most 1000 per page; a page without // images is not listed; images_enabled off takes the files out of the index // and answers 404. func TestImageSitemap(t *testing.T) { e := bootJournal(t) at := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) contentStore.put(postType, post(1, "a", "en", at, "/media/a.jpg", "/media/a.jpg", "https://cdn.example.test/b.png", "javascript:alert(1)", "/media/c&d.webp")) contentStore.put(postType, post(2, "b", "en", at.Add(time.Hour))) many := make([]string, 1200) for i := range many { many[i] = fmt.Sprintf("/media/many-%d.jpg", i) } contentStore.put(postType, post(3, "c", "en", at.Add(2*time.Hour), many...)) e.rebuild(t) index := e.get(t, "/sitemap.xml").Body.String() if got := locs(index); strings.Join(got, " ") != "https://api.example.test/sitemap-posts-1.xml https://api.example.test/sitemap-images-1.xml" { t.Fatalf("index locs = %v\n%s", got, index) } rec := e.get(t, "/_sitemap/files/sitemap-images-1.xml") body := rec.Body.String() if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/xml" || rec.Header().Get("X-Robots-Tag") != "noindex" { t.Fatalf("image file: %d %v %s", rec.Code, rec.Header(), body) } if !strings.Contains(body, ``) { t.Fatalf("image file head:\n%s", body[:min(len(body), 300)]) } if got := locs(body); strings.Join(got, " ") != "https://api.example.test/blog/a https://api.example.test/blog/c" { t.Fatalf("image file pages = %v", got) } first := body[strings.Index(body, "https://api.example.test/blog/a"):] first = first[:strings.Index(first, "")] if got := imageLocs(first); strings.Join(got, " ") != "https://api.example.test/media/a.jpg https://cdn.example.test/b.png https://api.example.test/media/c&d.webp" { t.Fatalf("post a images = %v\n%s", got, first) } if n := len(imageLocs(body)); n != 3+1000 { t.Fatalf("image file lists %d images, want 1003", n) } for _, banned := range []string{"image:caption", "image:title", "javascript:", "", "xhtml"} { if strings.Contains(body, banned) { t.Errorf("image file contains %q", banned) } } if rec := e.get(t, "/_sitemap/files/sitemap-images-2.xml"); rec.Code != http.StatusNotFound { t.Errorf("image file 2: %d", rec.Code) } // Stable numbering: with a file size of 1 the second page with images // is renumbered into its own file once, and a page that loses its images // leaves a hole instead of moving the later file. e.setFileSize(t, 1) e.rebuild(t) if got := locs(e.get(t, "/_sitemap/files/sitemap-images-2.xml").Body.String()); strings.Join(got, " ") != "https://api.example.test/blog/c" { t.Fatalf("image file 2 after the size change = %v", got) } contentStore.put(postType, post(1, "a", "en", at)) e.rebuild(t) if rec := e.get(t, "/_sitemap/files/sitemap-images-1.xml"); rec.Code != http.StatusNotFound { t.Errorf("image file 1 without images: %d %s", rec.Code, rec.Body.String()) } if got := locs(e.get(t, "/_sitemap/files/sitemap-images-2.xml").Body.String()); strings.Join(got, " ") != "https://api.example.test/blog/c" { t.Fatalf("image file 2 moved = %v", got) } e.exec(t, `UPDATE golem15_sitemap_settings SET images_enabled = FALSE WHERE id = 1`) if index := e.get(t, "/sitemap.xml").Body.String(); strings.Contains(index, "sitemap-images") { t.Fatalf("index with the image sitemap off:\n%s", index) } if rec := e.get(t, "/_sitemap/files/sitemap-images-2.xml"); rec.Code != http.StatusNotFound { t.Errorf("image file with the image sitemap off: %d", rec.Code) } } // newsLocs lists the values of a News sitemap. func newsLocs(body string) []string { return locs(body) } // TestNewsSitemap pins D-31's News sitemap (RESEARCH Pitfall 9): with // news_enabled the index lists sitemap-news.xml with the articles first // published in the last two days (1 and 47 hours ago, not 49), newest // first, never a record of another kind; each with the publication name // (news_publication, else app.name), the language of its locale, the // publication date and the title; news_sources narrows it to the listed // types and categories; more than 1000 candidates list the newest 1000; // the settings form refuses a malformed source and lists the values to // use; news_enabled off takes the file out of the index and answers 404. func TestNewsSitemap(t *testing.T) { e := bootJournal(t) now := time.Now().UTC().Truncate(time.Second) fresh := post(1, "fresh", "pl", now.Add(-time.Hour)) fresh.Locales[0].Title = `Grzyby & "huby"` fresh.Parents = []pact.ContentRef{{Type: categoryType, ID: "3"}} contentStore.put(postType, fresh) contentStore.put(postType, post(2, "older", "en", now.Add(-47*time.Hour))) contentStore.put(postType, post(3, "stale", "en", now.Add(-49*time.Hour))) cat := now.Add(-time.Hour) contentStore.put(categoryType, fakeRecord{ID: 3, Published: true, LastMod: cat, PublishedAt: &cat, Locales: []pact.ContentLocale{{Locale: "pl", URL: "/kategoria/lecznicze", Title: "Lecznicze"}}}) e.rebuild(t) // Off by default: not in the index, the file is a 404. if index := e.get(t, "/sitemap.xml").Body.String(); strings.Contains(index, "sitemap-news.xml") { t.Fatalf("index with the News sitemap off:\n%s", index) } if rec := e.get(t, "/_sitemap/files/sitemap-news.xml"); rec.Code != http.StatusNotFound { t.Fatalf("News file while off: %d", rec.Code) } // The settings form: the Images and News tab, a malformed source is // refused, the partial lists the values. token := e.admin(t, permSitemap) const base = "/golem15/sitemap/settings" schema := e.call(t, http.MethodGet, base+"/schema/form", token, nil).Body.String() for _, want := range []string{`"images_enabled"`, `"news_enabled"`, `"news_publication"`, `"news_sources"`, "Images and News"} { if !strings.Contains(schema, want) { t.Errorf("form schema lacks %s", want) } } for _, bad := range []any{[]string{"posts"}, []string{"category:acme-category"}, []string{"type:a b"}} { if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"news_sources": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"news_sources"`) { t.Errorf("news_sources %v: %d %s", bad, rec.Code, rec.Body.String()) } } rec := e.call(t, http.MethodGet, base+"/partials/news_sources", token, nil) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "type:acme-post") || !strings.Contains(rec.Body.String(), "category:acme-category:3") || !strings.Contains(rec.Body.String(), "Categories: Lecznicze") { t.Fatalf("news_sources partial: %d %s", rec.Code, rec.Body.String()) } if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"news_enabled": true, "news_sources": []string{}}); rec.Code != http.StatusOK { t.Fatalf("enable News: %d %s", rec.Code, rec.Body.String()) } index := e.get(t, "/sitemap.xml").Body.String() if !strings.Contains(index, "https://api.example.test/sitemap-news.xml") { t.Fatalf("index lacks the News file:\n%s", index) } rec = e.get(t, "/_sitemap/files/sitemap-news.xml") body := rec.Body.String() if rec.Code != http.StatusOK || rec.Header().Get("X-Robots-Tag") != "noindex" || !strings.Contains(body, `xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"`) { t.Fatalf("News file: %d %v\n%s", rec.Code, rec.Header(), body) } if got := newsLocs(body); strings.Join(got, " ") != "https://api.example.test/blog/fresh https://api.example.test/blog/older" { t.Fatalf("News articles = %v\n%s", got, body) } wantFresh := "https://api.example.test/blog/freshsitemap-smokepl" + now.Add(-time.Hour).Format(time.RFC3339) + "Grzyby & "huby"" if !strings.Contains(body, wantFresh) { t.Fatalf("News entry of the fresh post:\n%s\nwant %s", body, wantFresh) } // The publication name setting and the sources. e.exec(t, `UPDATE golem15_sitemap_settings SET news_publication = 'Gazeta', news_sources = '["category:acme-category:3"]' WHERE id = 1`) body = e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String() if got := newsLocs(body); strings.Join(got, " ") != "https://api.example.test/blog/fresh" || !strings.Contains(body, "Gazeta") { t.Fatalf("News with a category source = %v\n%s", got, body) } e.exec(t, `UPDATE golem15_sitemap_settings SET news_sources = '["type:acme-post"]' WHERE id = 1`) if got := newsLocs(e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String()); len(got) != 2 { t.Fatalf("News with a type source = %v", got) } e.exec(t, `UPDATE golem15_sitemap_settings SET news_sources = '["type:acme-category"]' WHERE id = 1`) if got := newsLocs(e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String()); len(got) != 0 { t.Fatalf("a collection type in the News sitemap = %v", got) } // More than 1000 candidates: the newest 1000. e.exec(t, `UPDATE golem15_sitemap_settings SET news_sources = '[]' WHERE id = 1`) for i := 0; i < 1005; i++ { contentStore.put(postType, post(100+i, fmt.Sprintf("bulk-%d", i), "en", now.Add(-2*time.Hour-time.Duration(i)*time.Minute))) } e.rebuild(t) got := newsLocs(e.get(t, "/_sitemap/files/sitemap-news.xml").Body.String()) if len(got) != 1000 || got[0] != "https://api.example.test/blog/fresh" || got[999] != "https://api.example.test/blog/bulk-998" { t.Fatalf("News with 1007 candidates lists %d, first %v last %v", len(got), got[:1], got[len(got)-1:]) } e.exec(t, `UPDATE golem15_sitemap_settings SET news_enabled = FALSE WHERE id = 1`) if index := e.get(t, "/sitemap.xml").Body.String(); strings.Contains(index, "sitemap-news.xml") { t.Fatalf("index after turning News off:\n%s", index) } if rec := e.get(t, "/_sitemap/files/sitemap-news.xml"); rec.Code != http.StatusNotFound { t.Fatalf("News file after turning it off: %d", rec.Code) } } // job returns the sitemap's registered job of kind. func (e *sitemapEnv) job(t *testing.T, kind string) pact.Job { 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 info, ok := conga.Describe(j); ok && info.Kind == kind { return j } } } t.Fatalf("the sitemap registers no job %s", kind) return nil } // fakeIndexNow is the IndexNow engine of a test: it records each request // and answers status. type fakeIndexNow struct { mu sync.Mutex status int requests []*http.Request bodies []map[string]any } func (f *fakeIndexNow) RoundTrip(r *http.Request) (*http.Response, error) { raw, _ := io.ReadAll(r.Body) body := map[string]any{} _ = json.Unmarshal(raw, &body) f.mu.Lock() f.requests = append(f.requests, r) f.bodies = append(f.bodies, body) status := f.status f.mu.Unlock() return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil } // queuedIndexNow decodes the URL lists of the queued IndexNow jobs, oldest // first. func (e *sitemapEnv) queuedIndexNow(t *testing.T) [][]string { t.Helper() var rows []string if err := e.gdb.Raw(`SELECT args::text FROM river_job WHERE kind = ? ORDER BY id`, classes.IndexNowKind).Scan(&rows).Error; err != nil { t.Fatal(err) } out := make([][]string, 0, len(rows)) for _, raw := range rows { var args classes.IndexNowArgs if err := json.Unmarshal([]byte(raw), &args); err != nil { t.Fatal(err) } out = append(out, args.URLs) } return out } // indexNowKey reads the stored key. func (e *sitemapEnv) indexNowKey(t *testing.T) string { t.Helper() var key *string if err := e.gdb.Raw(`SELECT indexnow_key FROM golem15_sitemap_settings WHERE id = 1`).Scan(&key).Error; err != nil { t.Fatal(err) } if key == nil { return "" } return *key } // TestIndexNow drives D-31's IndexNow in production: enabling and saving // generates a 32-hex key whose file answers text/plain (another name 404); // a rebuild with a new and a removed address queues one job with both; the // job posts {host, key, urlList} to api.indexnow.org and records the // answer (202); 403 and 422 are recorded without retry, 429 fails the job // so it retries; S2 shows each state; an address of another host is never // sent; a new key makes the old file 404. Outside production nothing is // queued or sent. func TestIndexNow(t *testing.T) { e := bootJournal(t, map[string]any{envOverride: "production"}) token := e.admin(t, permSitemap) const base = "/golem15/sitemap/settings" s2 := func() string { t.Helper() rec := e.call(t, http.MethodGet, base+"/partials/indexnow_status", token, nil) if rec.Code != http.StatusOK { t.Fatalf("S2: %d %s", rec.Code, rec.Body.String()) } return rec.Body.String() } if got := s2(); !strings.Contains(got, "A key is generated when you turn IndexNow on and save.") || !strings.Contains(got, "No notification sent yet.") { t.Fatalf("S2 before a key:\n%s", got) } schema := e.call(t, http.MethodGet, base+"/schema/form", token, nil).Body.String() for _, want := range []string{`"indexnow_enabled"`, `"indexnow_new_key"`, `"indexnow_status"`, "IndexNow"} { if !strings.Contains(schema, want) { t.Errorf("form schema lacks %s", want) } } // A rebuild before IndexNow is on queues nothing. at := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) contentStore.put(postType, post(1, "a", "en", at)) e.rebuild(t) if q := e.queuedIndexNow(t); len(q) != 0 { t.Fatalf("queued while off: %v", q) } if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"indexnow_enabled": true}); rec.Code != http.StatusOK { t.Fatalf("enable IndexNow: %d %s", rec.Code, rec.Body.String()) } key := e.indexNowKey(t) if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(key) { t.Fatalf("key = %q", key) } rec := e.get(t, "/_sitemap/indexnow/"+key+".txt") if rec.Code != http.StatusOK || rec.Body.String() != key || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") { t.Fatalf("key file: %d %q %v", rec.Code, rec.Body.String(), rec.Header()) } for _, name := range []string{"0123456789abcdef0123456789abcdef.txt", "short.txt", key + ".xml"} { if rec := e.get(t, "/_sitemap/indexnow/"+name); rec.Code != http.StatusNotFound { t.Errorf("key file %s: %d", name, rec.Code) } } if got := s2(); !strings.Contains(got, "Key: "+key+", served at https://api.example.test/"+key+".txt") { t.Fatalf("S2 with a key:\n%s", got) } // One new and one removed address: one job with both. contentStore.put(postType, post(2, "b", "en", at)) contentStore.remove(postType, 1) e.rebuild(t) q := e.queuedIndexNow(t) if len(q) != 1 || strings.Join(q[0], " ") != "https://api.example.test/blog/b https://api.example.test/blog/a" { t.Fatalf("queued = %v", q) } engine := &fakeIndexNow{status: http.StatusAccepted} ctx := fetchguard.WithTransport(t.Context(), engine) job := e.job(t, classes.IndexNowKind) args := classes.IndexNowArgs{URLs: append(q[0], "https://evil.test/x", "http://api.example.test.evil.test/y")} if err := job.Work(ctx, args); err != nil { t.Fatalf("IndexNow job: %v", err) } if len(engine.requests) != 1 { t.Fatalf("%d requests", len(engine.requests)) } req, body := engine.requests[0], engine.bodies[0] if req.Method != http.MethodPost || req.URL.String() != "https://api.indexnow.org/indexnow" || !strings.HasPrefix(req.Header.Get("Content-Type"), "application/json") { t.Fatalf("request = %s %s %v", req.Method, req.URL, req.Header) } list, _ := body["urlList"].([]any) if body["host"] != "api.example.test" || body["key"] != key || len(list) != 2 || body["keyLocation"] != nil { t.Fatalf("body = %v", body) } var last struct { Status int `gorm:"column:indexnow_last_status"` Count int `gorm:"column:indexnow_last_count"` } e.gdb.Raw(`SELECT indexnow_last_status, indexnow_last_count FROM golem15_sitemap_settings WHERE id = 1`).Scan(&last) if last.Status != 202 || last.Count != 2 { t.Fatalf("recorded = %+v", last) } if got := s2(); !strings.Contains(got, ", 2 addresses, answer 202.") || strings.Contains(got, "summer-callout--warning") { t.Fatalf("S2 after 202:\n%s", got) } for _, c := range []struct { status int retry bool want string }{ {http.StatusForbidden, false, "The engines could not read the key file at https://api.example.test/" + key + ".txt."}, {http.StatusUnprocessableEntity, false, "The engines refused the addresses because they do not match the site address."}, {http.StatusBadRequest, false, "answer 400."}, {http.StatusTooManyRequests, true, "Too many notifications; the next attempt is at "}, } { engine.status = c.status err := job.Work(ctx, classes.IndexNowArgs{URLs: q[0]}) if (err != nil) != c.retry { t.Errorf("status %d: job error %v, want retry %v", c.status, err, c.retry) } if got := s2(); !strings.Contains(got, c.want) { t.Errorf("S2 after %d lacks %q:\n%s", c.status, c.want, got) } } // A new key: the old file answers 404 at once. if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"indexnow_enabled": true, "indexnow_new_key": true}); rec.Code != http.StatusOK { t.Fatalf("new key: %d %s", rec.Code, rec.Body.String()) } fresh := e.indexNowKey(t) if fresh == key || len(fresh) != 32 { t.Fatalf("new key = %q (old %q)", fresh, key) } if rec := e.get(t, "/_sitemap/indexnow/"+key+".txt"); rec.Code != http.StatusNotFound { t.Fatalf("old key file: %d", rec.Code) } if rec := e.get(t, "/_sitemap/indexnow/"+fresh+".txt"); rec.Code != http.StatusOK { t.Fatalf("new key file: %d", rec.Code) } // A save without the checkbox keeps the key; turning IndexNow off // takes the file down. e.call(t, http.MethodPut, base+"/1", token, map[string]any{"indexnow_enabled": true}) if got := e.indexNowKey(t); got != fresh { t.Fatalf("a plain save changed the key to %q", got) } e.call(t, http.MethodPut, base+"/1", token, map[string]any{"indexnow_enabled": false}) if rec := e.get(t, "/_sitemap/indexnow/"+fresh+".txt"); rec.Code != http.StatusNotFound { t.Fatalf("key file while off: %d", rec.Code) } before := len(engine.requests) if err := job.Work(ctx, classes.IndexNowArgs{URLs: q[0]}); err != nil || len(engine.requests) != before { t.Fatalf("job while off: %v, %d requests", err, len(engine.requests)-before) } } // TestIndexNowStaging pins T-33-15: outside production a rebuild queues no // IndexNow job and the job sends nothing. func TestIndexNowStaging(t *testing.T) { e := bootJournal(t) e.exec(t, `UPDATE golem15_sitemap_settings SET indexnow_enabled = TRUE, indexnow_key = '0123456789abcdef0123456789abcdef' WHERE id = 1`) contentStore.put(postType, post(1, "a", "en", time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC))) e.rebuild(t) if q := e.queuedIndexNow(t); len(q) != 0 { t.Fatalf("queued outside production: %v", q) } engine := &fakeIndexNow{status: http.StatusOK} if err := e.job(t, classes.IndexNowKind).Work(fetchguard.WithTransport(t.Context(), engine), classes.IndexNowArgs{URLs: []string{"https://api.example.test/blog/a"}}); err != nil { t.Fatal(err) } if len(engine.requests) != 0 { t.Fatalf("%d requests outside production", len(engine.requests)) } if rec := e.get(t, "/_sitemap/indexnow/0123456789abcdef0123456789abcdef.txt"); rec.Code != http.StatusOK { t.Fatalf("key file outside production: %d", rec.Code) } } // htmlJSON fetches an HTML sitemap API path and decodes its answer. func (e *sitemapEnv) htmlJSON(t *testing.T, path string, status int) map[string]any { t.Helper() rec := e.get(t, path) if rec.Code != status { t.Fatalf("GET %s: %d, want %d: %s", path, rec.Code, status, rec.Body.String()) } if rec.Header().Get("X-Robots-Tag") != "noindex" { t.Errorf("GET %s lacks X-Robots-Tag noindex: %v", path, rec.Header()) } out := map[string]any{} if status == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode %s: %v", rec.Body.String(), err) } } return out } // titlesOfEntries lists the titles of decoded entries, children in // brackets. func titlesOfEntries(v any) string { list, _ := v.([]any) var parts []string for _, item := range list { e, _ := item.(map[string]any) s, _ := e["title"].(string) if kids, ok := e["children"]; ok { s += "[" + titlesOfEntries(kids) + "]" } parts = append(parts, s) } return strings.Join(parts, ", ") } // TestHTMLGroups pins D-34's HTML sitemap API: the overview of a locale // lists the groups in the L3 order (pages, offers, categories, posts) with // titles in the locale, categories nested by parent, posts newest first, // others alphabetical, the first html_page_size links and more when there // are more; a group page pages the rest; past the last page, an unknown // group and the HTML sitemap off are 404; excluded addresses never appear; // every answer is noindex. The settings tab validates its values and // contract.Listing answers the HTML sitemap and key file settings. func TestHTMLGroups(t *testing.T) { contentStore.reset(t) e := bootPlugins(t, []string{contentPluginID, journalPluginID, filterPluginID, "golem15.sitemap"}) at := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) pl := func(id int, url, title string) fakeRecord { return fakeRecord{ID: id, Published: true, LastMod: at, Locales: []pact.ContentLocale{{Locale: "pl", URL: url, Title: title}}} } contentStore.put(pageType, pl(1, "/pages/o-nas", "O nas")) contentStore.put(pageType, pl(2, "/pages/kontakt", "Kontakt")) contentStore.put(pageType, pl(3, "/pages/noindex-me", "Ukryta")) contentStore.put(pageType, fakeRecord{ID: 4, Published: true, LastMod: at, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/about", Title: "About"}}}) contentStore.put(offerType, pl(1, "/offers/zestaw", "Zestaw")) cat := func(id int, slug, title string, parent int) fakeRecord { r := pl(id, "/kategoria/"+slug, title) if parent > 0 { r.Parents = []pact.ContentRef{{Type: categoryType, ID: fmt.Sprint(parent)}} } return r } contentStore.put(categoryType, cat(1, "grzyby", "Grzyby", 0)) contentStore.put(categoryType, cat(2, "lecznicze", "Lecznicze", 1)) contentStore.put(categoryType, cat(3, "jadalne", "Jadalne", 1)) contentStore.put(categoryType, cat(4, "inne", "Inne", 0)) for i := 1; i <= 160; i++ { contentStore.put(postType, post(i, fmt.Sprintf("p%03d", i), "pl", at.Add(time.Duration(i)*time.Hour))) } e.exec(t, `INSERT INTO golem15_sitemap_types (type_key, include, slug, titles, updated_at) VALUES (?, TRUE, '', '{"pl":"Wpisy","en":"Posts"}', NOW())`, postType) e.rebuild(t) ov := e.htmlJSON(t, "/_sitemap/api/v1/html?locale=pl", http.StatusOK) groups, _ := ov["groups"].([]any) var order []string byKey := map[string]map[string]any{} for _, g := range groups { m := g.(map[string]any) order = append(order, m["slug"].(string)) byKey[m["slug"].(string)] = m } if strings.Join(order, " ") != "pages offers categories posts" { t.Fatalf("group order = %v\n%v", order, ov) } if got := titlesOfEntries(byKey["pages"]["entries"]); got != "Kontakt, O nas" { t.Errorf("pages = %s", got) } if got := titlesOfEntries(byKey["categories"]["entries"]); got != "Grzyby[Jadalne, Lecznicze], Inne" { t.Errorf("categories tree = %s", got) } posts := byKey["posts"] entries, _ := posts["entries"].([]any) if posts["title"] != "Wpisy" || posts["total"] != float64(160) || posts["more"] != true || len(entries) != 150 { t.Fatalf("posts group = title %v total %v more %v entries %d", posts["title"], posts["total"], posts["more"], len(entries)) } if first := entries[0].(map[string]any); first["title"] != "Post p160" || first["url"] != "https://api.example.test/blog/p160" { t.Errorf("newest post first: %v", first) } if byKey["categories"]["title"] != "Categories" || byKey["pages"]["more"] != false { t.Errorf("categories title %v, pages more %v", byKey["categories"]["title"], byKey["pages"]["more"]) } all := fmt.Sprint(ov) for _, banned := range []string{"Ukryta", "noindex-me", "About"} { if strings.Contains(all, banned) { t.Errorf("the pl overview lists %q", banned) } } if en := fmt.Sprint(e.htmlJSON(t, "/_sitemap/api/v1/html?locale=en", http.StatusOK)); !strings.Contains(en, "About") || strings.Contains(en, "Kontakt") { t.Errorf("the en overview: %s", en) } page2 := e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl&page=2", http.StatusOK) if p2, _ := page2["entries"].([]any); page2["page"] != float64(2) || page2["pages"] != float64(2) || page2["group"] != "posts" || page2["title"] != "Wpisy" || len(p2) != 10 { t.Fatalf("posts page 2 = %v", page2) } if got := titlesOfEntries(page2["entries"]); !strings.HasPrefix(got, "Post p010, Post p009") || !strings.HasSuffix(got, "Post p001") { t.Errorf("posts page 2 = %s", got) } if p1 := e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl", http.StatusOK); p1["page"] != float64(1) { t.Errorf("posts without page = %v", p1["page"]) } if got := titlesOfEntries(e.htmlJSON(t, "/_sitemap/api/v1/html/categories?locale=pl", http.StatusOK)["entries"]); got != "Grzyby[Jadalne, Lecznicze], Inne" { t.Errorf("categories page = %s", got) } for _, path := range []string{ "/_sitemap/api/v1/html/posts?locale=pl&page=3", "/_sitemap/api/v1/html/posts?locale=pl&page=0", "/_sitemap/api/v1/html/posts?locale=pl&page=x", "/_sitemap/api/v1/html/unknown?locale=pl", "/_sitemap/api/v1/html/offers?locale=en", "/_sitemap/api/v1/html?locale=%3Cx%3E", } { e.htmlJSON(t, path, http.StatusNotFound) } // The settings tab. token := e.admin(t, permSitemap) const base = "/golem15/sitemap/settings" schema := e.call(t, http.MethodGet, base+"/schema/form", token, nil).Body.String() for _, want := range []string{`"html_enabled"`, `"html_path"`, `"html_page_size"`, "HTML sitemap"} { if !strings.Contains(schema, want) { t.Errorf("form schema lacks %s", want) } } for _, c := range []struct { body map[string]any field string }{ {map[string]any{"html_page_size": 49}, "html_page_size"}, {map[string]any{"html_page_size": 501}, "html_page_size"}, {map[string]any{"html_path": "mapa"}, "html_path"}, {map[string]any{"html_path": "/mapa?x=1"}, "html_path"}, {map[string]any{"html_path": "//evil.test"}, "html_path"}, } { if rec := e.call(t, http.MethodPut, base+"/1", token, c.body); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) { t.Errorf("%v: %d %s", c.body, rec.Code, rec.Body.String()) } } listing, ok := e.app.Lookup[contract.Listing]() if !ok { t.Fatal("no contract.Listing") } ls, err := listing.Settings(t.Context()) if err != nil { t.Fatal(err) } if !ls.HTMLEnabled || ls.HTMLPageSize != 150 || ls.HTMLPaths["pl"] != "/mapa-strony" || ls.HTMLPaths["en"] != "/sitemap" || ls.KeyFile != "" { t.Fatalf("listing settings = %+v", ls) } if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{"html_path": "/site-map", "html_page_size": 100}); rec.Code != http.StatusOK { t.Fatalf("save the HTML tab: %d %s", rec.Code, rec.Body.String()) } e.exec(t, `UPDATE golem15_sitemap_settings SET indexnow_enabled = TRUE, indexnow_key = '0123456789abcdef0123456789abcdef' WHERE id = 1`) ls, _ = listing.Settings(t.Context()) if ls.HTMLPageSize != 100 || ls.HTMLPaths["en"] != "/site-map" || ls.HTMLPaths["pl"] != "/mapa-strony" || ls.KeyFile != "0123456789abcdef0123456789abcdef.txt" { t.Fatalf("listing settings after the save = %+v", ls) } if p := e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl&page=2", http.StatusOK); p["pages"] != float64(2) || len(p["entries"].([]any)) != 60 { t.Errorf("posts page 2 at size 100 = pages %v", p["pages"]) } e.call(t, http.MethodPut, base+"/1", token, map[string]any{"html_enabled": false}) e.htmlJSON(t, "/_sitemap/api/v1/html?locale=pl", http.StatusNotFound) e.htmlJSON(t, "/_sitemap/api/v1/html/posts?locale=pl", http.StatusNotFound) if ls, _ := listing.Settings(t.Context()); ls.HTMLEnabled { t.Error("listing says the HTML sitemap is on") } }