- every write that records history (admin, MCP, batch, import, undo, automatic) queues the framework's site-cache purge (kind seo, slug the address or * for settings and robots.txt) after its commit, once per transaction; a rolled-back write never purges - the record-deleted cleanup and the address tracking of saves outside the admin run in lagoon transactions, so their history purges as well
597 lines
30 KiB
Go
597 lines
30 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
)
|
|
|
|
// pagesAPI is the Pages screen's admin API.
|
|
var pagesAPI = adminAPI("/golem15/seo/pages")
|
|
|
|
// createRegistryPage creates a registry page through the admin form and
|
|
// returns its id.
|
|
func (e *testEnv) createRegistryPage(t *testing.T, token string, body map[string]any) string {
|
|
t.Helper()
|
|
return createdID(t, e.expect(t, http.StatusCreated, http.MethodPost, pagesAPI, token, body).Body.Bytes())
|
|
}
|
|
|
|
// refusal posts body and expects a 422 whose answer contains want.
|
|
func (e *testEnv) refusal(t *testing.T, method, path, token string, body map[string]any, want string) {
|
|
t.Helper()
|
|
rec := e.call(t, method, path, token, body)
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), want) {
|
|
t.Fatalf("%s %s %v = %d %s, want 422 with %q", method, path, body, rec.Code, rec.Body.String(), want)
|
|
}
|
|
}
|
|
|
|
// TestRegistry covers the page registry (D-33, UI-SPEC C21): a page made
|
|
// in the Pages screen is a seo-page record with the SEO tab, resolves ok,
|
|
// is listed in the sitemap file of its kind with hreflang alternates, the
|
|
// C21 refusals, the delete, the filter groups and the management API.
|
|
func TestRegistry(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
|
|
contact := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/kontakt", "pl": ""}, "title": map[string]any{"en": "Kontakt"}, "kind": "page"})
|
|
got := env.resolve(t, "/kontakt")
|
|
seoBody, _ := got["seo"].(map[string]any)
|
|
if got["action"] != "ok" || !strings.Contains(seoBody["title"].(string), "Kontakt") {
|
|
t.Fatalf("resolve /kontakt = %v, want ok with the page name in the title", got)
|
|
}
|
|
|
|
// The registry is the content type seo-page with the SEO tab.
|
|
reg, _ := env.app.Lookup[*signpost.Registry]()
|
|
if typ, ok := reg.ContentType("seo-page"); !ok || typ.Content == nil || typ.Content.Controller != "golem15.seo.pages" || typ.Content.Kind != "page" {
|
|
t.Fatalf("seo-page content type = %+v ok=%v", typ, ok)
|
|
}
|
|
rec, err := reg.Describe(t.Context(), "seo-page", contact, "")
|
|
if err != nil || rec == nil || len(rec.Locales) != 1 || rec.Locales[0].URL != "/kontakt" || rec.Locales[0].Title != "Kontakt" || rec.LastMod == nil {
|
|
t.Fatalf("describe = %+v err=%v, want one en locale /kontakt with a content date (no pl address)", rec, err)
|
|
}
|
|
findField(t, env.formSchema(t, token, "golem15/seo/pages"), "seo_title")
|
|
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+contact, token, map[string]any{"seo_title": map[string]any{"en": "Contact us"}})
|
|
if seoBody, _ := env.resolve(t, "/kontakt")["seo"].(map[string]any); seoBody["title"] != "Contact us" {
|
|
t.Fatalf("after the SEO tab save the title = %v, want Contact us", seoBody["title"])
|
|
}
|
|
|
|
// Two locales: two URLs and hreflang alternates.
|
|
about := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/about", "pl": "/o-firmie"}, "title": map[string]any{"en": "About", "pl": "O firmie"}})
|
|
if got := env.resolve(t, "/o-firmie"); got["action"] != "ok" || got["locale"] != "pl" {
|
|
t.Fatalf("resolve /o-firmie = %v, want ok in pl", got)
|
|
}
|
|
show := env.expect(t, http.StatusOK, http.MethodGet, pagesAPI+"/"+about, token, nil).Body.String()
|
|
if !jsonContains(show, `"url":{"en":"/about","pl":"/o-firmie"}`) || !jsonContains(show, `"title":{"en":"About","pl":"O firmie"}`) {
|
|
t.Fatalf("show = %s, want the address and the name per locale", show)
|
|
}
|
|
offer := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/oferta"}, "title": map[string]any{"en": "Oferta"}, "kind": "offer"})
|
|
|
|
env.rebuildSitemap(t)
|
|
pages := env.sitemapFile(t, "sitemap-pages-1.xml")
|
|
for _, want := range []string{".test/kontakt</loc>", ".test/about</loc>", ".test/o-firmie</loc>", `hreflang="pl"`} {
|
|
if !strings.Contains(pages, want) {
|
|
t.Fatalf("sitemap-pages-1.xml lacks %s:\n%s", want, pages)
|
|
}
|
|
}
|
|
if strings.Contains(pages, "/oferta<") {
|
|
t.Fatalf("the offer is in the pages file:\n%s", pages)
|
|
}
|
|
if offers := env.sitemapFile(t, "sitemap-offers-1.xml"); !strings.Contains(offers, ".test/oferta</loc>") {
|
|
t.Fatalf("sitemap-offers-1.xml lacks the offer:\n%s", offers)
|
|
}
|
|
|
|
// The C21 refusals.
|
|
env.createPage(t, token, map[string]any{"title": "Lwia grzywa", "slug": "lwia-grzywa", "published": true})
|
|
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "/pages/lwia-grzywa"}}, "This address belongs to Page “Lwia grzywa”")
|
|
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "/kontakt"}}, "Another page already uses this address.")
|
|
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "/nowa", "pl": "/o-firmie"}}, "Another page already uses this address.")
|
|
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "kontakt"}}, "Start the address with / and leave out the domain.")
|
|
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "https://example.test/x"}}, "Start the address with / and leave out the domain.")
|
|
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "", "pl": "/tylko-pl"}}, "Enter the address in the default language.")
|
|
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"title": map[string]any{"en": "No address"}}, "Enter the address in the default language.")
|
|
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+about, token, map[string]any{"parent_id": contact})
|
|
env.refusal(t, http.MethodPut, pagesAPI+"/"+contact, token, map[string]any{"parent_id": about}, "A page cannot be placed under itself or one of its subpages.")
|
|
env.refusal(t, http.MethodPut, pagesAPI+"/"+contact, token, map[string]any{"parent_id": contact}, "A page cannot be placed under itself or one of its subpages.")
|
|
var count int64
|
|
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_pages`).Scan(&count).Error; err != nil || count != 3 {
|
|
t.Fatalf("pages after the refusals = %d err=%v, want 3", count, err)
|
|
}
|
|
if rec, err := reg.Describe(t.Context(), "seo-page", about, ""); err != nil || len(rec.Parents) != 1 || rec.Parents[0].ID != contact {
|
|
t.Fatalf("about parents = %+v err=%v, want the contact page", rec, err)
|
|
}
|
|
|
|
// A changed address gets its 301, from the admin and from the API.
|
|
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+about, token, map[string]any{"url": map[string]any{"en": "/about-us", "pl": "/o-firmie"}})
|
|
if got := env.resolve(t, "/about"); got["action"] != "redirect" || got["location"] != siteURL+"/about-us" {
|
|
t.Fatalf("resolve of the old address = %v, want a redirect to /about-us", got)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/pages/"+offer, token, map[string]any{"url": "/oferty"})
|
|
if got := env.resolve(t, "/oferta"); got["action"] != "redirect" || got["location"] != siteURL+"/oferty" {
|
|
t.Fatalf("resolve of the old API address = %v, want a redirect to /oferty", got)
|
|
}
|
|
|
|
// The filter's groups: the four kinds with their titles per locale.
|
|
filter, ok := env.app.Lookup[contract.Filter]()
|
|
if !ok {
|
|
t.Fatal("no contract.Filter published")
|
|
}
|
|
groups, err := filter.Groups(t.Context())
|
|
if err != nil || len(groups) != 4 {
|
|
t.Fatalf("groups = %+v err=%v", groups, err)
|
|
}
|
|
keys := []string{}
|
|
for _, g := range groups {
|
|
keys = append(keys, g.Key)
|
|
}
|
|
if strings.Join(keys, ",") != "pages,offers,services,landing-pages" || groups[3].Titles["en"] != "Landing page" || groups[0].Titles["pl"] != "Strona" {
|
|
t.Fatalf("groups = %+v", groups)
|
|
}
|
|
|
|
// Deleting a page deletes its SEO entries; it leaves the sitemaps.
|
|
env.expect(t, http.StatusOK, http.MethodDelete, pagesAPI+"/"+contact, token, nil)
|
|
var entries int64
|
|
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE model_type = ? AND record_id = ?`, `Golem15\SEO\Models\Page`, contact).Scan(&entries).Error; err != nil || entries != 0 {
|
|
t.Fatalf("entries after delete = %d err=%v, want 0", entries, err)
|
|
}
|
|
env.rebuildSitemap(t)
|
|
if pages := env.sitemapFile(t, "sitemap-pages-1.xml"); strings.Contains(pages, ".test/kontakt</loc>") {
|
|
t.Fatalf("the deleted page is still listed:\n%s", pages)
|
|
}
|
|
if got := env.resolve(t, "/kontakt"); got["action"] != "not_found" {
|
|
t.Fatalf("resolve of the deleted page = %v, want not_found", got)
|
|
}
|
|
|
|
// The management API (the MCP tools' routes).
|
|
api := "/_seo/api/v1/pages"
|
|
created := env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/uslugi", "title": "Usługi", "kind": "service"}).Body.String()
|
|
var answer struct {
|
|
Data struct {
|
|
ID uint `json:"id"`
|
|
Origin string `json:"origin"`
|
|
URLs map[string]string `json:"urls"`
|
|
Kind string `json:"kind"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(created), &answer); err != nil || answer.Data.ID == 0 || answer.Data.Origin != "manual" || answer.Data.Kind != "service" || answer.Data.URLs["en"] != "/uslugi" {
|
|
t.Fatalf("create answer = %s err=%v", created, err)
|
|
}
|
|
id := jsonID(answer.Data.ID)
|
|
env.expect(t, http.StatusOK, http.MethodPut, api+"/"+id, token, map[string]any{"url": map[string]any{"pl": "/uslugi-pl"}, "note": "x"})
|
|
list := env.expect(t, http.StatusOK, http.MethodGet, api+"?kind=service", token, nil).Body.String()
|
|
if !jsonContains(list, `"urls":{"en":"/uslugi","pl":"/uslugi-pl"}`) || !jsonContains(list, `"total":1`) {
|
|
t.Fatalf("list = %s", list)
|
|
}
|
|
rec2 := env.call(t, http.MethodPost, api, token, map[string]any{"url": "/about-us"})
|
|
if rec2.Code != http.StatusUnprocessableEntity || !strings.Contains(rec2.Body.String(), "Another page already uses this address.") {
|
|
t.Fatalf("API duplicate = %d %s", rec2.Code, rec2.Body.String())
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodDelete, api+"/"+id, token, nil)
|
|
env.expect(t, http.StatusNotFound, http.MethodDelete, api+"/"+id, token, nil)
|
|
other := env.admin(t, false)
|
|
env.expect(t, http.StatusForbidden, http.MethodGet, api, other, nil)
|
|
env.expect(t, http.StatusUnauthorized, http.MethodGet, api, "", nil)
|
|
}
|
|
|
|
// jsonID is an id as path text.
|
|
func jsonID(id uint) string {
|
|
raw, _ := json.Marshal(id)
|
|
return string(raw)
|
|
}
|
|
|
|
// pageRow reads a registry page's stored values by its address.
|
|
type pageRow struct {
|
|
ID uint `gorm:"column:id"`
|
|
URL string `gorm:"column:url"`
|
|
Title string `gorm:"column:title"`
|
|
Kind string `gorm:"column:kind"`
|
|
Origin string `gorm:"column:origin"`
|
|
Note string `gorm:"column:note"`
|
|
Stamp string `gorm:"column:stamp"`
|
|
}
|
|
|
|
func (e *testEnv) pageRow(t *testing.T, url string) pageRow {
|
|
t.Helper()
|
|
var rows []pageRow
|
|
if err := e.db.Raw(`SELECT id, url, title, kind, origin, note, COALESCE(reported_at::text, '') AS stamp FROM golem15_seo_pages WHERE url = ?`, url).Scan(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("pages at %s = %d, want 1", url, len(rows))
|
|
}
|
|
return rows[0]
|
|
}
|
|
|
|
// reportRoutes posts a route report with the bearer token.
|
|
func (e *testEnv) reportRoutes(t *testing.T, token string, routes []string) map[string]any {
|
|
t.Helper()
|
|
rec := e.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/registry/routes", token, map[string]any{"routes": routes})
|
|
if rec.Header().Get("X-Robots-Tag") != "noindex" {
|
|
t.Fatalf("route report headers = %v, want X-Robots-Tag noindex", rec.Header())
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestRouteReport covers the route report (D-33, P33-20, T-33-18): the
|
|
// token, the size cap, insert-only behaviour, reported_at, the missing
|
|
// badge and P9 warning, owned paths skipped, and that a later report never
|
|
// deletes a page or overwrites an editor's values.
|
|
func TestRouteReport(t *testing.T) {
|
|
off := bootEnv(t)
|
|
off.expect(t, http.StatusNotFound, http.MethodPost, "/_seo/api/v1/registry/routes", "anything", map[string]any{"routes": []string{"/"}})
|
|
|
|
// notfound_token is the fallback token.
|
|
fallback := bootEnv(t, "SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=nf-token")
|
|
fallback.reportRoutes(t, "nf-token", []string{"/x"})
|
|
|
|
env := bootEnv(t, "SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=report-token", "SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=other")
|
|
admin := env.admin(t, true)
|
|
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_seo/api/v1/registry/routes", "wrong", map[string]any{"routes": []string{"/"}})
|
|
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_seo/api/v1/registry/routes", "other", map[string]any{"routes": []string{"/"}})
|
|
env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_seo/api/v1/registry/routes", "", map[string]any{"routes": []string{"/"}})
|
|
many := make([]string, 2001)
|
|
for i := range many {
|
|
many[i] = "/p"
|
|
}
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, "/_seo/api/v1/registry/routes", "report-token", map[string]any{"routes": many})
|
|
|
|
env.createRegistryPage(t, admin, map[string]any{"url": map[string]any{"en": "/kontakt"}, "title": map[string]any{"en": "Kontakt"}})
|
|
env.createPage(t, admin, map[string]any{"title": "Blog post", "slug": "post", "published": true})
|
|
|
|
out := env.reportRoutes(t, "report-token", []string{"/", "/kontakt", "/o-nas", "/pages/post", "nope", "/o-nas"})
|
|
if out["inserted"] != float64(2) || out["updated"] != float64(1) || out["skipped"] != float64(2) {
|
|
t.Fatalf("first report = %v, want 2 inserted (/, /o-nas), 1 updated, 2 skipped", out)
|
|
}
|
|
about := env.pageRow(t, "/o-nas")
|
|
if about.Origin != "reported" || about.Kind != "page" || about.Title != "O nas" || about.Stamp == "" {
|
|
t.Fatalf("/o-nas = %+v, want origin reported, kind page, title O nas, reported", about)
|
|
}
|
|
if home := env.pageRow(t, "/"); home.Title != "Home" {
|
|
t.Fatalf("home = %+v, want the title Home", home)
|
|
}
|
|
contact := env.pageRow(t, "/kontakt")
|
|
if contact.Origin != "manual" || contact.Title != "Kontakt" || contact.Stamp == "" {
|
|
t.Fatalf("/kontakt = %+v, want the editor's page with reported_at", contact)
|
|
}
|
|
var owned int64
|
|
env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_pages WHERE url = '/pages/post'`).Scan(&owned)
|
|
if owned != 0 {
|
|
t.Fatal("a path owned by a content type was inserted")
|
|
}
|
|
if got := env.resolve(t, "/o-nas"); got["action"] != "ok" {
|
|
t.Fatalf("resolve /o-nas = %v, want ok", got)
|
|
}
|
|
|
|
// The editor changes the reported page; the same report again changes
|
|
// only reported_at.
|
|
id := jsonID(about.ID)
|
|
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+id, admin, map[string]any{"title": map[string]any{"en": "About us"}, "kind": "offer", "note": "kept"})
|
|
var revisions int64
|
|
env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions`).Scan(&revisions)
|
|
before := env.pageRow(t, "/o-nas")
|
|
out = env.reportRoutes(t, "report-token", []string{"/", "/kontakt", "/o-nas"})
|
|
if out["inserted"] != float64(0) || out["updated"] != float64(3) {
|
|
t.Fatalf("second report = %v, want 0 inserted, 3 updated", out)
|
|
}
|
|
after := env.pageRow(t, "/o-nas")
|
|
if after.Title != "About us" || after.Kind != "offer" || after.Note != "kept" || after.Origin != "reported" || after.Stamp == before.Stamp {
|
|
t.Fatalf("after the second report /o-nas = %+v (before %+v), want only reported_at changed", after, before)
|
|
}
|
|
var revisionsAfter int64
|
|
env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions`).Scan(&revisionsAfter)
|
|
if revisionsAfter != revisions {
|
|
t.Fatalf("revisions %d -> %d: a report that inserts nothing writes no history", revisions, revisionsAfter)
|
|
}
|
|
|
|
// A report without /o-nas marks it missing and deletes nothing.
|
|
env.reportRoutes(t, "report-token", []string{"/", "/kontakt"})
|
|
missing := env.pageRow(t, "/o-nas")
|
|
if missing.Title != "About us" {
|
|
t.Fatalf("missing page = %+v, want it kept with its values", missing)
|
|
}
|
|
var list struct {
|
|
Meta struct {
|
|
RowStates map[string][]string `json:"row_states"`
|
|
} `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(env.expect(t, http.StatusOK, http.MethodGet, pagesAPI, admin, nil).Body.Bytes(), &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if states := list.Meta.RowStates[id]; len(states) != 1 || states[0] != "negative" {
|
|
t.Fatalf("row states = %v, want /o-nas (%s) negative", list.Meta.RowStates, id)
|
|
}
|
|
if states := list.Meta.RowStates[jsonID(contact.ID)]; len(states) != 0 {
|
|
t.Fatalf("the manual page has row states %v", states)
|
|
}
|
|
status := env.expect(t, http.StatusOK, http.MethodGet, pagesAPI+"/partials/page_status?id="+id, admin, nil).Body.String()
|
|
if !strings.Contains(status, "summer-callout--warning") || !strings.Contains(status, "did not include this address") {
|
|
t.Fatalf("P9 of the missing page = %s", status)
|
|
}
|
|
if home := env.expect(t, http.StatusOK, http.MethodGet, pagesAPI+"/partials/page_status?id="+jsonID(env.pageRow(t, "/").ID), admin, nil).Body.String(); !strings.Contains(home, "Reported by the site on") || strings.Contains(home, "--warning") {
|
|
t.Fatalf("P9 of a reported page = %s", home)
|
|
}
|
|
if manual := env.expect(t, http.StatusOK, http.MethodGet, pagesAPI+"/partials/page_status?id="+jsonID(contact.ID), admin, nil).Body.String(); strings.Contains(manual, "summer-callout") {
|
|
t.Fatalf("P9 of a manual page = %s, want nothing", manual)
|
|
}
|
|
missingList := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/pages?missing=only", admin, nil).Body.String()
|
|
if !jsonContains(missingList, `"total":1`) || !jsonContains(missingList, `"missing":true`) {
|
|
t.Fatalf("missing=only list = %s", missingList)
|
|
}
|
|
// Reported again, it is back on the site.
|
|
env.reportRoutes(t, "report-token", []string{"/", "/kontakt", "/o-nas"})
|
|
if again := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/pages?missing=only", admin, nil).Body.String(); !jsonContains(again, `"total":0`) {
|
|
t.Fatalf("missing=only after the page is reported again = %s", again)
|
|
}
|
|
}
|
|
|
|
// outboundOf reads the resolve answer's outbound rules.
|
|
func outboundOf(t *testing.T, got map[string]any) map[string]any {
|
|
t.Helper()
|
|
out, ok := got["outbound"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("resolve answer without outbound: %v", got)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSettingsLinks covers the Links tab (UI-SPEC C20, D-83, P33-16): the
|
|
// new-tab switch on by default, the domain lists with their line-numbered
|
|
// refusal, the MCP-writable keys and outbound in the resolve answer.
|
|
func TestSettingsLinks(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
findField(t, env.formSchema(t, token, "golem15/seo/settings"), "nofollow_domains")
|
|
env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true})
|
|
|
|
out := outboundOf(t, env.resolve(t, "/pages/about"))
|
|
if out["new_tab"] != true || len(out["nofollow_domains"].([]any)) != 0 || len(out["sponsored_domains"].([]any)) != 0 {
|
|
t.Fatalf("default outbound = %v, want new_tab true and no domains", out)
|
|
}
|
|
if got := env.resolve(t, "/pages/missing"); got["outbound"] != nil {
|
|
t.Fatalf("a not_found answer carries outbound: %v", got)
|
|
}
|
|
|
|
rec := env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"nofollow_domains": "example.com\nhttps://x.pl/a"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `Line 2: “https://x.pl/a” is not a domain.`) {
|
|
t.Fatalf("admin save of a bad domain = %d %s, want 422 naming line 2", rec.Code, rec.Body.String())
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"nofollow_domains": "Example.com\n\n shop.example.org "})
|
|
var stored string
|
|
if err := env.db.Raw(`SELECT nofollow_domains FROM golem15_seo_settings WHERE id = 1`).Scan(&stored).Error; err != nil || stored != "example.com\nshop.example.org" {
|
|
t.Fatalf("stored nofollow_domains = %q err=%v", stored, err)
|
|
}
|
|
|
|
api := "/_seo/api/v1/settings"
|
|
rec = env.call(t, http.MethodPut, api, token, map[string]any{"sponsored_domains": "partner.pl\nnot a domain"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "sponsored_domains") || !strings.Contains(rec.Body.String(), "Line 2") {
|
|
t.Fatalf("API save of a bad domain = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, api, token, map[string]any{"sponsored_domains": "partner.pl", "links_new_tab": false})
|
|
show := env.expect(t, http.StatusOK, http.MethodGet, api, token, nil).Body.String()
|
|
if !jsonContains(show, `"links_new_tab":false`) || !jsonContains(show, `"sponsored_domains":"partner.pl"`) {
|
|
t.Fatalf("settings = %s", show)
|
|
}
|
|
out = outboundOf(t, env.resolve(t, "/pages/about"))
|
|
raw, _ := json.Marshal(out)
|
|
if string(raw) != `{"new_tab":false,"nofollow_domains":["example.com","shop.example.org"],"sponsored_domains":["partner.pl"]}` {
|
|
t.Fatalf("outbound = %s", raw)
|
|
}
|
|
}
|
|
|
|
// TestSiteEndpoint covers GET /_seo/api/v1/site (P33-17, T-33-19): every
|
|
// key, the verification and IndexNow root files, each content type's
|
|
// pattern (not the registry's), the HTML sitemap settings, noindex.
|
|
func TestSiteEndpoint(t *testing.T) {
|
|
env := bootEnv(t, "SUMMER_GOLEM15__SEO__UNPREFIXED_LOCALE=en")
|
|
token := env.admin(t, true)
|
|
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{
|
|
"verify_files": []map[string]any{{"filename": "google0123.html", "body": "google-site-verification: google0123.html"}},
|
|
"links_new_tab": false,
|
|
})
|
|
key := "0123456789abcdef0123456789abcdef"
|
|
if err := env.db.Exec(`UPDATE golem15_sitemap_settings SET indexnow_enabled = TRUE, indexnow_key = ?, html_enabled = TRUE WHERE id = 1`, key).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil)
|
|
if rec.Header().Get("X-Robots-Tag") != "noindex" {
|
|
t.Fatalf("site headers = %v, want X-Robots-Tag noindex", rec.Header())
|
|
}
|
|
var site map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &site); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{"site_url", "default_locale", "locales", "unprefixed_locale", "links", "html_sitemap", "routes", "root_files", "llms"} {
|
|
if _, ok := site[k]; !ok {
|
|
t.Fatalf("site answer lacks %s: %s", k, rec.Body.String())
|
|
}
|
|
}
|
|
if site["site_url"] != siteURL || site["default_locale"] != "en" || site["unprefixed_locale"] != "en" || site["llms"] != false {
|
|
t.Fatalf("site answer = %s", rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`"locales":["en","pl"]`,
|
|
`"links":{"new_tab":false}`,
|
|
`"root_files":["google0123.html","` + key + `.txt"]`,
|
|
`{"type":"acme-page","pattern":"/pages/:slug"}`,
|
|
`{"type":"acme-tag","pattern":"/pages/tag/:slug"}`,
|
|
`"html_sitemap":{"enabled":true,"paths":{`,
|
|
} {
|
|
if !jsonContains(body, want) {
|
|
t.Fatalf("site answer lacks %s: %s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "seo-page") || strings.Contains(body, "token") {
|
|
t.Fatalf("site answer names the registry type or a token: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestLLMSTxt covers /llms.txt (D-25, UI-SPEC C25, T-33-20): off by default
|
|
// (404), on in production it lists the indexable pages from the sitemaps
|
|
// per group, headed by the site name and the introduction; excluded
|
|
// addresses never appear; staging answers 404.
|
|
func TestLLMSTxt(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
env.expect(t, http.StatusNotFound, http.MethodGet, "/llms.txt", "", nil)
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"site_name": "Acme", "default_description": "Acme makes things."})
|
|
env.createPage(t, token, map[string]any{"title": "About [us]", "slug": "about", "published": true})
|
|
hidden := env.createPage(t, token, map[string]any{"title": "Hidden", "slug": "hidden", "published": true})
|
|
env.createPage(t, token, map[string]any{"title": "Draft", "slug": "draft", "published": false})
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+hidden), token, map[string]any{
|
|
"title": "Hidden", "slug": "hidden", "published": true, "seo_robots": map[string]any{"en": "noindex"},
|
|
})
|
|
env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/kontakt"}, "title": map[string]any{"en": "Contact"}})
|
|
env.rebuildSitemap(t)
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", token, map[string]any{"llms_enabled": true})
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/llms.txt", "", nil)
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
|
t.Fatalf("content type = %q", ct)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
"# Acme\n\n> Acme makes things.\n",
|
|
"- [About \\[us\\]](https://app.example.test/pages/about)\n",
|
|
"## Page\n\n",
|
|
"- [Contact](https://app.example.test/kontakt)\n",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("llms.txt lacks %q:\n%s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "/pages/hidden") || strings.Contains(body, "/pages/draft") || strings.Contains(body, "/pl/") {
|
|
t.Fatalf("llms.txt lists a left-out or other-locale address:\n%s", body)
|
|
}
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", token, map[string]any{"llms_intro": "Things, made well."})
|
|
if body := env.expect(t, http.StatusOK, http.MethodGet, "/llms.txt", "", nil).Body.String(); !strings.Contains(body, "\n> Things, made well.\n") {
|
|
t.Fatalf("llms.txt intro:\n%s", body)
|
|
}
|
|
show := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots", token, nil).Body.String()
|
|
if !jsonContains(show, `"llms_enabled":true`) || !jsonContains(show, `"llms_intro":"Things, made well."`) {
|
|
t.Fatalf("robots answer = %s", show)
|
|
}
|
|
if site := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil).Body.String(); !jsonContains(site, `"llms":true`) {
|
|
t.Fatalf("site answer = %s", site)
|
|
}
|
|
|
|
staging := bootEnv(t, "SUMMER_ENV=staging")
|
|
stoken := staging.admin(t, true)
|
|
staging.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", stoken, map[string]any{"llms_enabled": true})
|
|
staging.expect(t, http.StatusNotFound, http.MethodGet, "/llms.txt", "", nil)
|
|
}
|
|
|
|
// TestRobotsSitemapLine covers robots.txt's Sitemap line (D-22): without
|
|
// golem15.seo.robots_sitemaps it names the sitemap plugin's index only;
|
|
// with it, the configured lines.
|
|
func TestRobotsSitemapLine(t *testing.T) {
|
|
env := bootEnv(t)
|
|
body := env.expect(t, http.StatusOK, http.MethodGet, "/robots.txt", "", nil).Body.String()
|
|
if strings.Count(body, "Sitemap:") != 1 || !strings.Contains(body, "Sitemap: https://app.example.test/sitemap.xml\n") {
|
|
t.Fatalf("robots.txt without robots_sitemaps:\n%s", body)
|
|
}
|
|
custom := bootEnv(t, "SUMMER_GOLEM15__SEO__ROBOTS_SITEMAPS=/a.xml,/b.xml")
|
|
body = custom.expect(t, http.StatusOK, http.MethodGet, "/robots.txt", "", nil).Body.String()
|
|
if !strings.Contains(body, "Sitemap: "+siteURL+"/a.xml\nSitemap: "+siteURL+"/b.xml\n") || strings.Contains(body, "app.example.test") {
|
|
t.Fatalf("robots.txt with robots_sitemaps:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// purgeLog is a fake site-cache purge route: it records each request's
|
|
// kind and slug.
|
|
type purgeLog struct {
|
|
mu sync.Mutex
|
|
bodies []map[string]string
|
|
}
|
|
|
|
func (p *purgeLog) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]string
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
if r.Header.Get("Authorization") != "Bearer "+purgeToken {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
p.bodies = append(p.bodies, body)
|
|
p.mu.Unlock()
|
|
_, _ = w.Write([]byte(`{"purged":1}`))
|
|
}
|
|
|
|
// take waits for the queued purges and returns the requests since the
|
|
// last take.
|
|
func (p *purgeLog) take(t *testing.T, env *testEnv) []map[string]string {
|
|
t.Helper()
|
|
if err := cooler.SiteCacheFromApp(env.app).Wait(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
out := p.bodies
|
|
p.bodies = nil
|
|
return out
|
|
}
|
|
|
|
const purgeToken = "seo-test-purge-token-0123456789abcdef"
|
|
|
|
// TestPurgeAfterWrite covers the site-cache purge after SEO writes (P33-19,
|
|
// D-97, T-33-23): an SEO tab save, a redirect created through MCP, a
|
|
// settings save and an applied batch each post one purge (kind seo) after
|
|
// the commit; a refused batch and a dry run post none.
|
|
func TestPurgeAfterWrite(t *testing.T) {
|
|
log := &purgeLog{}
|
|
srv := httptest.NewServer(log)
|
|
defer srv.Close()
|
|
env := bootEnv(t, "SUMMER_SITE_CACHE__PURGE_URL="+srv.URL+"/api/__site__/cache/purge", "SUMMER_SITE_CACHE__PURGE_TOKEN="+purgeToken)
|
|
token := env.admin(t, true)
|
|
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true})
|
|
log.take(t, env)
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
|
|
"title": "About", "slug": "about", "published": true,
|
|
"seo_title": map[string]any{"en": "About us", "pl": "O nas"}, "seo_description": map[string]any{"en": "Who we are"},
|
|
})
|
|
if got := log.take(t, env); len(got) != 1 || got[0]["kind"] != "seo" {
|
|
t.Fatalf("purges after the SEO tab save = %v, want one of kind seo", got)
|
|
}
|
|
|
|
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old", "target": "/pages/about"})
|
|
if got := log.take(t, env); len(got) != 1 || got[0]["slug"] != "/old" {
|
|
t.Fatalf("purges after the MCP redirect = %v, want one naming /old", got)
|
|
}
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"twitter_site": "@acme"})
|
|
if got := log.take(t, env); len(got) != 1 || got[0]["slug"] != "*" {
|
|
t.Fatalf("purges after the settings save = %v, want one with slug *", got)
|
|
}
|
|
|
|
changes := []any{redirectChange("/b1", "/pages/about"), redirectChange("/b2", "/pages/about"), redirectChange("/b3", "/pages/about")}
|
|
env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"dry_run": true, "changes": changes})
|
|
if got := log.take(t, env); len(got) != 0 {
|
|
t.Fatalf("purges after a dry run = %v, want none", got)
|
|
}
|
|
env.mcpExpect(t, http.StatusUnprocessableEntity, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{
|
|
redirectChange("/l1", "/l2"), redirectChange("/l2", "/l1"),
|
|
}})
|
|
if got := log.take(t, env); len(got) != 0 {
|
|
t.Fatalf("purges after a refused batch = %v, want none", got)
|
|
}
|
|
env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": changes})
|
|
if got := log.take(t, env); len(got) != 1 {
|
|
t.Fatalf("purges after an applied batch of three = %v, want one", got)
|
|
}
|
|
}
|