- GORM stamped created_at and updated_at in the process's local time, which the timestamp columns keep as the wall clock; east of UTC a page without a content date got a sitemap lastmod in the future - Page.BeforeSave stamps both in UTC for the admin's and the API's saves, and the admin's address change writes updated_at in UTC
650 lines
32 KiB
Go
650 lines
32 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// TestRegistryTimestampsUTC pins the registry page's created_at and
|
|
// updated_at in UTC on a machine east of UTC: the admin create, the
|
|
// management API's update and the admin's address change each store the
|
|
// UTC wall clock, so a page without a content date never gets a lastmod
|
|
// in the future.
|
|
func TestRegistryTimestampsUTC(t *testing.T) {
|
|
local := time.Local
|
|
time.Local = time.FixedZone("UTC+3", 3*60*60)
|
|
t.Cleanup(func() { time.Local = local })
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
|
|
stamps := func(id string) (created, updated time.Time) {
|
|
t.Helper()
|
|
var row struct {
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
if err := env.db.Raw(`SELECT created_at, updated_at FROM golem15_seo_pages WHERE id = ?`, id).Scan(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return row.CreatedAt, row.UpdatedAt
|
|
}
|
|
within := func(what string, got, from, to time.Time) {
|
|
t.Helper()
|
|
if got.Before(from.Add(-time.Second)) || got.After(to.Add(time.Second)) {
|
|
t.Fatalf("%s = %s, want between %s and %s (UTC)", what, got.Format(time.RFC3339), from.Format(time.RFC3339), to.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
from := time.Now().UTC()
|
|
id := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/kontakt"}, "title": map[string]any{"en": "Kontakt"}})
|
|
created, updated := stamps(id)
|
|
within("created_at after the admin create", created, from, time.Now().UTC())
|
|
within("updated_at after the admin create", updated, from, time.Now().UTC())
|
|
|
|
from = time.Now().UTC()
|
|
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/pages/"+id, token, map[string]any{"title": "Kontakt z nami", "content_updated_at": ""})
|
|
_, updated = stamps(id)
|
|
within("updated_at after the API update", updated, from, time.Now().UTC())
|
|
reg, _ := env.app.Lookup[*signpost.Registry]()
|
|
rec, err := reg.Describe(t.Context(), "seo-page", id, "")
|
|
if err != nil || rec == nil || rec.LastMod == nil || rec.LastMod.After(time.Now().UTC().Add(time.Second)) {
|
|
t.Fatalf("describe = %+v err=%v, want a lastmod no later than now", rec, err)
|
|
}
|
|
|
|
from = time.Now().UTC()
|
|
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+id, token, map[string]any{"url": map[string]any{"en": "/kontakt-z-nami"}})
|
|
_, updated = stamps(id)
|
|
within("updated_at after the admin address change", updated, from, time.Now().UTC())
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|