package seo_test import ( "encoding/json" "net/http" "strings" "testing" "git.golem15.com/golem15/sm-sitemap-plugin/contract" "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", ".test/about", ".test/o-firmie", `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") { 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") { 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) } }