Files
sm-seo-plugin/registry_test.go
Jakub Zych 9e802ec850 feat: page registry with its admin screen and signpost type
- golem15_seo_pages (migration 202610280002): the site's own frontend pages
  with an address and a name per locale, kind, origin, parent, content date
  and note; settings routes_reported_at
- SEO > Pages (golem15.seo.pages) after URL rules: list with kind, origin
  and missing filters and the missing row state, form with the P9 partial;
  the plugin's form extension saves the address and name per locale and
  refuses url_required, url_invalid, url_taken and url_owned
- menu item type seo-page with a content descriptor, so each page gets
  the SEO tab, resolves ok and is listed in the sitemaps grouped by kind
  (pages, offers, services, landing-pages, titled per locale)
- management API GET/POST /_seo/api/v1/pages, PUT/DELETE /pages/{id} and
  the MCP tools seo_list_pages, seo_create_page, seo_update_page,
  seo_delete_page (in this commit because the operations parity test maps
  every Pages operation to its tool)
2026-11-04 13:20:45 +01:00

185 lines
10 KiB
Go

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</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)
}