Files
sm-seo-plugin/structured_data_test.go
Jakub Zych 032d3dae45 feat: pagination forms and suffixes in resolve and site
- Pagination tab: query or path form, per-locale segment, title and
  description suffixes with %page% and %pages%
- ?page=1, /<segment>/1 and the other form answer 301 to the configured
  address in one hop with normalisation and strict URLs; a page value
  that is not a number answers not_found with pagination.invalid
- page 2 and later: self canonical with the page, index,follow, the
  suffixes (%pages% left for the layer), paged hreflang alternates
- GET /site answers pagination {form, param, segments} and home_crumb
2026-11-04 14:22:21 +01:00

601 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package seo_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// The fake content plugin acme.blog: posts (kind article) under
// categories (kind collection) with authors (kind profile), all in memory,
// describing the 33-01 content facts (first publication, parents, authors,
// images, videos). It knows nothing about the SEO plugin.
const (
blogPluginID = "acme.magazine"
postTypeKey = "acme-story"
postModel = "acme_magazine_stories"
categoryTypeKey = "acme-section"
categoryModel = "acme_magazine_sections"
authorTypeKey = "acme-writer"
authorModel = "acme_magazine_writers"
)
// blogRecords are the fake records per type key; tests may change them.
var (
blogMu sync.Mutex
blogRecords map[string][]pact.ContentRecord
)
func intPtr(n int) *int { return &n }
func timePtr(s string) *time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return &t
}
// resetBlog seeds the fake records.
func resetBlog() {
blogMu.Lock()
defer blogMu.Unlock()
blogRecords = map[string][]pact.ContentRecord{
postTypeKey: {
{
ID: "1", Published: true,
PublishedAt: timePtr("2026-10-01T08:00:00Z"),
LastMod: timePtr("2026-10-05T12:30:00Z"),
Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "1"}, {Type: categoryTypeKey, ID: "2"}},
Authors: []pact.ContentPerson{{Name: "Jan Kowalski", Ref: pact.ContentRef{Type: authorTypeKey, ID: "7"}}},
Locales: []pact.ContentLocale{
{
Locale: "en", URL: "/blog/first-post", Title: "First post", Description: "About the first post",
Image: "/media/first.jpg", Words: intPtr(420),
Images: []pact.ContentImage{{URL: "/media/first.jpg", Alt: "A mushroom"}, {URL: "/media/deco.png", Decorative: true}},
Videos: []pact.ContentVideo{{Provider: "youtube", ID: "dQw4w9WgXcQ"}},
},
{Locale: "pl", URL: "/pl/blog/pierwszy-wpis", Title: "Pierwszy wpis", Description: "O pierwszym wpisie", Words: intPtr(410)},
},
},
{
ID: "2", Published: true, PublishedAt: timePtr("2026-10-02T08:00:00Z"),
Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/solo", Title: "Solo"}},
},
{
ID: "3", Published: true, PublishedAt: timePtr("2026-10-03T08:00:00Z"),
Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "3"}},
Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/nested", Title: "Nested"}},
},
},
categoryTypeKey: {
{ID: "1", Published: true, Locales: []pact.ContentLocale{
{Locale: "en", URL: "/blog/category/recipes", Title: "Recipes"},
{Locale: "pl", URL: "/pl/blog/kategoria/przepisy", Title: "Przepisy"},
}},
{ID: "2", Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/category/mushrooms", Title: "Mushrooms"}}},
{ID: "3", Published: true, Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "4"}}, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/category/soups", Title: "Soups"}}},
{ID: "4", Published: false, Parents: []pact.ContentRef{{Type: categoryTypeKey, ID: "1"}}, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/category/hidden", Title: "Hidden"}}},
},
authorTypeKey: {
{ID: "7", Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/blog/author/jan", Title: "Jan Kowalski", Image: "/media/jan.jpg"}}},
},
}
}
func init() {
resetBlog()
party.Register(&magazinePlugin{})
}
// magazinePlugin is acme.blog.
type magazinePlugin struct{}
func (*magazinePlugin) ID() string { return blogPluginID }
func (*magazinePlugin) Requires() []string { return nil }
func (*magazinePlugin) Register(*backpack.App) error { return nil }
func (*magazinePlugin) Boot(*backpack.App) error { return nil }
func (*magazinePlugin) MenuItemTypes() []pact.MenuItemType {
return []pact.MenuItemType{
blogType(postTypeKey, "Posts", postModel, "/blog/:slug", pact.ContentKindArticle),
blogType(categoryTypeKey, "Categories", categoryModel, "/blog/category/:slug", pact.ContentKindCollection),
blogType(authorTypeKey, "Authors", authorModel, "/blog/author/:slug", pact.ContentKindProfile),
}
}
var _ pact.HasMenuItemTypes = (*magazinePlugin)(nil)
// blogRecord returns a copy of a fake record.
func blogRecord(key, id string) *pact.ContentRecord {
blogMu.Lock()
defer blogMu.Unlock()
for _, r := range blogRecords[key] {
if r.ID == id {
c := r
return &c
}
}
return nil
}
// blogType is one in-memory content type of acme.blog.
func blogType(key, label, model, pattern, kind string) pact.MenuItemType {
return pact.MenuItemType{
Key: key,
Label: label,
DefaultPattern: pattern,
Placeholders: []string{"slug"},
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return nil, nil
},
Content: &pact.ContentDescriptor{
Model: model,
Kind: kind,
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
return blogRecord(key, in.ID), nil
},
Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) {
blogMu.Lock()
defer blogMu.Unlock()
out := pact.ContentPage{}
if in.After == "" {
out.Records = append(out.Records, blogRecords[key]...)
}
return out, nil
},
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
blogMu.Lock()
defer blogMu.Unlock()
for _, r := range blogRecords[key] {
for _, l := range r.Locales {
if l.URL == in.Path && r.Published {
return &pact.ContentMatchResult{ID: r.ID, Locale: l.Locale, Exact: true}, nil
}
}
}
return nil, nil
},
},
}
}
// bootBlog boots acme.blog with the SEO plugin, with the publisher
// Organization "Acme".
func bootBlog(t *testing.T, extraEnv ...string) *testEnv {
t.Helper()
resetBlog()
env := bootPlugins(t, []string{"acme.pages", blogPluginID, "golem15.seo"}, extraEnv...)
if err := env.db.Exec(`UPDATE golem15_seo_settings SET publisher_type = 'organization', publisher_name = 'Acme', site_name = 'Acme Blog' WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
return env
}
// graphNodes returns the resolve answer's @graph nodes keyed by @type.
func graphNodes(t *testing.T, res map[string]any) map[string]map[string]any {
t.Helper()
jsonld, ok := res["jsonld"].(map[string]any)
if !ok {
t.Fatalf("no jsonld in %v", res)
}
if jsonld["@context"] != "https://schema.org" {
t.Fatalf("@context = %v", jsonld["@context"])
}
list, ok := jsonld["@graph"].([]any)
if !ok {
t.Fatalf("no @graph in %v", jsonld)
}
out := map[string]map[string]any{}
for _, raw := range list {
n := raw.(map[string]any)
typ, _ := n["@type"].(string)
if _, dup := out[typ]; dup {
t.Fatalf("two %s nodes", typ)
}
out[typ] = n
}
return out
}
// idOf returns the @id of a reference value.
func idOf(v any) string {
m, _ := v.(map[string]any)
s, _ := m["@id"].(string)
return s
}
// asJSON compacts v.
func asJSON(t *testing.T, v any) string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestGraphTracer(t *testing.T) {
env := bootBlog(t)
post := siteURL + "/blog/first-post"
res := env.resolve(t, "/blog/first-post")
if res["action"] != "ok" {
t.Fatalf("action = %v", res)
}
pretty, _ := json.MarshalIndent(res, "", " ")
t.Logf("resolve answer:\n%s", pretty)
nodes := graphNodes(t, res)
for _, typ := range []string{"WebPage", "BlogPosting", "BreadcrumbList", "VideoObject", "WebSite", "Organization"} {
if nodes[typ] == nil {
t.Fatalf("no %s node in %s", typ, asJSON(t, res["jsonld"]))
}
}
page, article := nodes["WebPage"], nodes["BlogPosting"]
if page["@id"] != post+"#webpage" || idOf(page["isPartOf"]) != siteURL+"/#website" || idOf(page["breadcrumb"]) != post+"#breadcrumb" {
t.Fatalf("WebPage = %v", page)
}
if article["@id"] != post+"#article" || article["headline"] != "First post" || idOf(article["mainEntityOfPage"]) != post+"#webpage" || idOf(article["publisher"]) != siteURL+"/#organization" {
t.Fatalf("BlogPosting = %v", article)
}
if article["datePublished"] != "2026-10-01T08:00:00Z" || article["dateModified"] != "2026-10-05T12:30:00Z" || article["wordCount"] != float64(420) || article["articleSection"] != "Recipes" {
t.Fatalf("BlogPosting facts = %v", article)
}
authors, _ := article["author"].([]any)
if len(authors) != 1 || authors[0].(map[string]any)["name"] != "Jan Kowalski" || authors[0].(map[string]any)["url"] != siteURL+"/blog/author/jan" {
t.Fatalf("author = %v", article["author"])
}
if images, _ := article["image"].([]any); len(images) != 1 || images[0] != siteURL+"/media/first.jpg" {
t.Fatalf("image = %v (the decorative image stays out)", article["image"])
}
video := nodes["VideoObject"]
if video["@id"] != post+"#video-1" || video["embedUrl"] != "https://www.youtube.com/embed/dQw4w9WgXcQ" || video["uploadDate"] != "2026-10-01T08:00:00Z" || video["name"] != "First post" {
t.Fatalf("VideoObject = %v", video)
}
if org := nodes["Organization"]; org["@id"] != siteURL+"/#organization" || org["name"] != "Acme" {
t.Fatalf("Organization = %v", org)
}
if site := nodes["WebSite"]; site["@id"] != siteURL+"/#website" || idOf(site["publisher"]) != siteURL+"/#organization" || site["name"] != "Acme Blog" {
t.Fatalf("WebSite = %v", site)
}
if _, ok := nodes["WebSite"]["potentialAction"]; ok {
t.Fatal("SearchAction without search_url")
}
// The breadcrumbs of the answer are the BreadcrumbList items.
wantCrumbs := `[{"name":"Home","path":"/","url":"https://www.example.test/"},{"name":"Recipes","path":"/blog/category/recipes","url":"https://www.example.test/blog/category/recipes"},{"name":"First post","path":"/blog/first-post","url":"https://www.example.test/blog/first-post"}]`
if got := asJSON(t, res["breadcrumbs"]); got != wantCrumbs {
t.Fatalf("breadcrumbs = %s\nwant %s", got, wantCrumbs)
}
items, _ := nodes["BreadcrumbList"]["itemListElement"].([]any)
crumbs := res["breadcrumbs"].([]any)
if len(items) != len(crumbs) || nodes["BreadcrumbList"]["@id"] != post+"#breadcrumb" {
t.Fatalf("BreadcrumbList = %v", nodes["BreadcrumbList"])
}
for i, raw := range items {
item, crumb := raw.(map[string]any), crumbs[i].(map[string]any)
if item["position"] != float64(i+1) || item["name"] != crumb["name"] || item["item"] != crumb["url"] {
t.Fatalf("item %d = %v, crumb %v", i, item, crumb)
}
}
// hreflang from one source: en, pl and x-default on the default locale.
wantAlternates := `[{"hreflang":"en","url":"https://www.example.test/blog/first-post"},{"hreflang":"pl","url":"https://www.example.test/pl/blog/pierwszy-wpis"},{"hreflang":"x-default","url":"https://www.example.test/blog/first-post"}]`
if got := asJSON(t, res["alternates"]); got != wantAlternates {
t.Fatalf("alternates = %s", got)
}
og := res["seo"].(map[string]any)["og"].(map[string]any)
if res["html_lang"] != "en" || og["locale"] != "en_US" || asJSON(t, og["locale_alternates"]) != `["pl_PL"]` {
t.Fatalf("html_lang %v, og %v", res["html_lang"], og)
}
t.Run("TestAlternates", func(t *testing.T) {
pl := env.resolve(t, "/pl/blog/pierwszy-wpis")
if got := asJSON(t, pl["alternates"]); got != wantAlternates {
t.Fatalf("pl alternates = %s", got)
}
og := pl["seo"].(map[string]any)["og"].(map[string]any)
if pl["html_lang"] != "pl" || og["locale"] != "pl_PL" || asJSON(t, og["locale_alternates"]) != `["en_US"]` {
t.Fatalf("pl html_lang %v, og %v", pl["html_lang"], og)
}
solo := env.resolve(t, "/blog/solo")
if _, ok := solo["alternates"]; ok {
t.Fatalf("single-locale record has alternates: %v", solo["alternates"])
}
if og := solo["seo"].(map[string]any)["og"].(map[string]any); asJSON(t, og["locale_alternates"]) != `[]` {
t.Fatalf("single-locale og alternates = %v", og["locale_alternates"])
}
missing := env.resolve(t, "/blog/missing")
for _, key := range []string{"html_lang", "alternates", "jsonld", "breadcrumbs"} {
if _, ok := missing[key]; ok {
t.Fatalf("not_found answer carries %s", key)
}
}
})
t.Run("TestBreadcrumbs", func(t *testing.T) {
pl := env.resolve(t, "/pl/blog/pierwszy-wpis")
want := `[{"name":"Strona główna","path":"/pl","url":"https://www.example.test/pl"},{"name":"Przepisy","path":"/pl/blog/kategoria/przepisy","url":"https://www.example.test/pl/blog/kategoria/przepisy"},{"name":"Pierwszy wpis","path":"/pl/blog/pierwszy-wpis","url":"https://www.example.test/pl/blog/pierwszy-wpis"}]`
if got := asJSON(t, pl["breadcrumbs"]); got != want {
t.Fatalf("pl breadcrumbs = %s", got)
}
// The parent chain is followed recursively; an unpublished parent is
// left out but its own parent is kept.
nested := env.resolve(t, "/blog/nested")
if got := crumbNames(nested); got != "Home > Recipes > Soups > Nested" {
t.Fatalf("nested trail = %s", got)
}
// A stored primary parent wins over the first parent.
if err := env.db.Exec(`INSERT INTO golem15_seo_entries (model_type, record_id, locale, type_key, data, created_at, updated_at) VALUES (?, '1', 'en', ?, ?, NOW(), NOW())`,
postModel, postTypeKey, `{"primary_parent":"acme-section:2"}`).Error; err != nil {
t.Fatal(err)
}
chosen := env.resolve(t, "/blog/first-post")
if got := crumbNames(chosen); got != "Home > Mushrooms > First post" {
t.Fatalf("primary parent trail = %s", got)
}
if section := graphNodes(t, chosen)["BlogPosting"]["articleSection"]; section != "Mushrooms" {
t.Fatalf("articleSection = %v", section)
}
// The home page's trail is the home crumb alone, without a
// BreadcrumbList node.
home := env.resolve(t, "/")
if got := crumbNames(home); got != "Home" {
t.Fatalf("home trail = %s", got)
}
if graphNodes(t, home)["BreadcrumbList"] != nil {
t.Fatal("home page has a BreadcrumbList")
}
})
}
// crumbNames joins the answer's crumb names.
func crumbNames(res map[string]any) string {
list, _ := res["breadcrumbs"].([]any)
var names []string
for _, raw := range list {
names = append(names, raw.(map[string]any)["name"].(string))
}
return strings.Join(names, " > ")
}
// refusal asserts a 422 save answer naming field with message.
func refusal(t *testing.T, rec *httptest.ResponseRecorder, field, message string) {
t.Helper()
body := rec.Body.String()
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(body, `"`+field+`"`) || !strings.Contains(body, message) {
t.Fatalf("save = %d %s, want 422 on %s with %q", rec.Code, body, field, message)
}
}
// TestPublisherSettings covers the Publisher and Local business tabs
// (UI-SPEC C20, D-45, D-03): the saved values, every C20 refusal (422) in
// the admin and the API, the local business node and the P8 preview.
func TestPublisherSettings(t *testing.T) {
env := bootBlog(t)
token := env.admin(t, true)
fields := env.formSchema(t, token, "golem15/seo/settings")
for _, name := range []string{"breadcrumb_home", "publisher_type", "publisher_name", "publisher_logo", "publisher_same_as", "local_enabled", "local_hours", "_publisher_preview"} {
findField(t, fields, name)
}
settings := adminAPI("/golem15/seo/settings/1")
env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{
"publisher_type": "organization", "publisher_name": "Acme Sp. z o.o.", "publisher_url": "https://acme.example.test/",
"publisher_email": "office@acme.example.test", "publisher_phone": "+48 600 000 000",
"publisher_same_as": "https://www.facebook.com/acme\nhttps://pl.wikipedia.org/wiki/Acme",
})
show := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/settings", token, nil).Body.String()
for _, want := range []string{`"publisher_name":"Acme Sp. z o.o."`, `"publisher_phone":"+48 600 000 000"`, `"local_enabled":false`} {
if !jsonContains(show, want) {
t.Fatalf("settings lack %s: %s", want, show)
}
}
// P8 without a logo: the node and a recommended-logo warning.
preview := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/partials/publisher_preview?id=1"), token, nil).Body.String()
if !strings.Contains(preview, "Acme Sp. z o.o.") || !strings.Contains(preview, `Missing recommended property “logo” (Organization)`) {
t.Fatalf("P8 = %s", preview)
}
env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{"publisher_logo": "/media/logo.png"})
preview = env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/partials/publisher_preview?id=1"), token, nil).Body.String()
if !strings.Contains(preview, "No problems") || strings.Contains(preview, "Missing") {
t.Fatalf("P8 with a logo = %s", preview)
}
// The C20 refusals, in the admin form.
refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_same_as": "https://ok.example.test\nx.pl"}), "publisher_same_as", "Line 2: use a full address starting with https://.")
refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_same_as": "x.pl"}), "publisher_same_as", "Line 1: use a full address")
refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_type": "person", "local_enabled": true, "local_street": "Prosta 1", "local_postal_code": "00-001", "local_city": "Warszawa"}), "local_enabled", "A person cannot be a local business.")
local := map[string]any{"publisher_type": "organization", "local_enabled": true, "local_street": "Prosta 1", "local_postal_code": "00-001", "local_city": "Warszawa"}
with := func(k string, v any) map[string]any {
out := map[string]any{}
for key, value := range local {
out[key] = value
}
out[k] = v
return out
}
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_street", "")), "local_street", "A local business needs a street, a postal code and a city.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_hours", []map[string]any{{"day": "mon", "opens": "9:00", "closes": "17:00"}})), "local_hours.0.opens", "Use 24-hour time, e.g. 09:00.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_hours", []map[string]any{{"day": "mon", "opens": "17:00", "closes": "09:00"}})), "local_hours.0.closes", "Closing must come after opening.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_country", "POL")), "local_country", "Use a two-letter country code, e.g. PL.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_latitude", "91")), "local_latitude", "Enter decimal degrees")
// The same checks through the API (MCP seo_update_settings).
refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"local_enabled": true, "publisher_type": "person"}), "local_enabled", "A person cannot be a local business.")
refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"publisher_same_as": "x.pl"}), "publisher_same_as", "Line 1: use a full address")
// A valid local business: the publisher node becomes its type with
// the address, geo, opening hours and price range.
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{
"local_enabled": true, "local_type": "FoodEstablishment", "local_street": "Prosta 1", "local_postal_code": "00-001",
"local_city": "Warszawa", "local_country": "pl", "local_latitude": "52.2297", "local_longitude": "21.0122",
"local_price_range": "$$", "local_hours": []map[string]any{{"day": "mon", "opens": "09:00", "closes": "13:00"}, {"day": "mon", "opens": "14:00", "closes": "18:00"}},
})
nodes := graphNodes(t, env.resolve(t, "/"))
biz := nodes["FoodEstablishment"]
if biz == nil || biz["@id"] != siteURL+"/#organization" || biz["priceRange"] != "$$" || biz["telephone"] != "+48 600 000 000" {
t.Fatalf("local business node = %v", nodes)
}
if got := asJSON(t, biz["address"]); got != `{"@type":"PostalAddress","addressCountry":"PL","addressLocality":"Warszawa","postalCode":"00-001","streetAddress":"Prosta 1"}` {
t.Fatalf("address = %s", got)
}
if got := asJSON(t, biz["geo"]); got != `{"@type":"GeoCoordinates","latitude":52.2297,"longitude":21.0122}` {
t.Fatalf("geo = %s", got)
}
if hours, _ := biz["openingHoursSpecification"].([]any); len(hours) != 2 || hours[1].(map[string]any)["opens"] != "14:00" || hours[0].(map[string]any)["dayOfWeek"] != "Monday" {
t.Fatalf("hours = %v", biz["openingHoursSpecification"])
}
if got := asJSON(t, biz["sameAs"]); got != `["https://www.facebook.com/acme","https://pl.wikipedia.org/wiki/Acme"]` {
t.Fatalf("sameAs = %s", got)
}
if biz["url"] != "https://acme.example.test/" || asJSON(t, biz["logo"]) != `{"@id":"https://www.example.test/#logo","@type":"ImageObject","contentUrl":"https://www.example.test/media/logo.png","url":"https://www.example.test/media/logo.png"}` {
t.Fatalf("url/logo = %v %v", biz["url"], biz["logo"])
}
// A person publisher: #person, no local business.
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"local_enabled": false, "publisher_type": "person", "publisher_name": "Jan Kowalski"})
nodes = graphNodes(t, env.resolve(t, "/"))
if p := nodes["Person"]; p == nil || p["@id"] != siteURL+"/#person" || p["name"] != "Jan Kowalski" || idOf(nodes["WebSite"]["publisher"]) != siteURL+"/#person" {
t.Fatalf("person publisher = %v", nodes)
}
// The home crumb name.
if got := crumbNames(env.resolve(t, "/blog/solo")); got != "Home > Solo" {
t.Fatalf("default home crumb trail = %s", got)
}
env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{"breadcrumb_home": "Start"})
if got := crumbNames(env.resolve(t, "/blog/solo")); got != "Start > Solo" {
t.Fatalf("home crumb trail = %s", got)
}
}
// expectMoved asserts a 301 answer to location (one hop).
func expectMoved(t *testing.T, env *testEnv, target, location string) {
t.Helper()
res := env.resolve(t, target)
if res["action"] != "redirect" || res["status"] != float64(301) || res["location"] != siteURL+location {
t.Fatalf("%s = %v %v %v, want 301 %s", target, res["action"], res["status"], res["location"], location)
}
if next := env.resolve(t, location); next["action"] != "ok" {
t.Fatalf("%s → %s answers %v, want ok (one hop)", target, location, next["action"])
}
}
// expectInvalidPage asserts a not_found answer with pagination.invalid.
func expectInvalidPage(t *testing.T, env *testEnv, target string) {
t.Helper()
res := env.resolve(t, target)
p, _ := res["pagination"].(map[string]any)
if res["action"] != "not_found" || p == nil || p["invalid"] != true {
t.Fatalf("%s = %v pagination %v, want not_found with invalid", target, res["action"], res["pagination"])
}
}
// TestPagination covers the page address forms (D-40 to D-42, D-21,
// P33-24): 301s to the configured form in one hop, invalid values, page 2
// and later with index,follow, a self canonical and the suffixes, and the
// site answer.
func TestPagination(t *testing.T) {
env := bootBlog(t)
token := env.admin(t, true)
const list = "/blog/category/recipes"
t.Run("query form", func(t *testing.T) {
expectMoved(t, env, list+"?page=1", list)
res := env.resolve(t, list+"?page=3")
seo := res["seo"].(map[string]any)
if res["action"] != "ok" || res["url"] != siteURL+list+"?page=3" || seo["canonical"] != siteURL+list+"?page=3" || seo["robots"] != "index, follow" {
t.Fatalf("page 3 = %v", res)
}
if seo["title"] != "Recipes – page 3 of %pages% | Acme Blog" || seo["description"] != "Page 3 of %pages%." {
t.Fatalf("page 3 title %q description %q", seo["title"], seo["description"])
}
if got := asJSON(t, res["pagination"]); got != `{"form":"query","invalid":false,"page":3,"param":"page","segment":""}` {
t.Fatalf("pagination = %s", got)
}
pl := env.resolve(t, "/pl/blog/kategoria/przepisy?page=3")
if title := pl["seo"].(map[string]any)["title"]; title != "Przepisy – strona 3 z %pages% | Acme Blog" {
t.Fatalf("pl page 3 title = %v", title)
}
for _, bad := range []string{"?page=abc", "?page=0", "?page=-1"} {
expectInvalidPage(t, env, list+bad)
}
expectMoved(t, env, list+"/page/3", list+"?page=3")
expectMoved(t, env, "/pl/blog/kategoria/przepisy/strona/3", "/pl/blog/kategoria/przepisy?page=3")
if res := env.resolve(t, list); res["pagination"] != nil {
t.Fatalf("unnumbered page carries pagination: %v", res["pagination"])
}
})
t.Run("one hop with normalisation", func(t *testing.T) {
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"url_lowercase": true})
expectMoved(t, env, "/Blog/Category/Recipes?page=1", list)
expectMoved(t, env, "/Blog/Category/Recipes/page/4", list+"?page=4")
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"url_lowercase": false})
})
t.Run("path form", func(t *testing.T) {
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"page_form": "path"})
expectMoved(t, env, list+"?page=3", list+"/page/3")
expectMoved(t, env, list+"/page/1", list)
expectInvalidPage(t, env, list+"/page/x")
expectInvalidPage(t, env, list+"?page=abc")
res := env.resolve(t, list+"/page/3")
if res["action"] != "ok" || res["url"] != siteURL+list+"/page/3" || res["seo"].(map[string]any)["canonical"] != siteURL+list+"/page/3" {
t.Fatalf("path page 3 = %v", res)
}
if got := asJSON(t, res["pagination"]); got != `{"form":"path","invalid":false,"page":3,"param":"page","segment":"page"}` {
t.Fatalf("pagination = %s", got)
}
if pl := env.resolve(t, "/pl/blog/kategoria/przepisy/strona/2"); pl["action"] != "ok" || pl["pagination"].(map[string]any)["segment"] != "strona" {
t.Fatalf("pl path page = %v", pl)
}
expectMoved(t, env, "/pl/blog/kategoria/przepisy/page/2", "/pl/blog/kategoria/przepisy/strona/2")
// A record whose own address ends like a page stays a record.
if res := env.resolve(t, "/blog/first-post"); res["action"] != "ok" {
t.Fatalf("post = %v", res["action"])
}
})
t.Run("TestSiteEndpoint", func(t *testing.T) {
site := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil).Body.String()
for _, want := range []string{`"pagination":{"form":"path","param":"page","segments":{"en":"page","pl":"strona"}}`, `"home_crumb":{"name":"Home","url":"https://www.example.test/","names":{"en":"Home","pl":"Strona główna"}}`} {
if !jsonContains(site, want) {
t.Fatalf("site answer lacks %s: %s", want, site)
}
}
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"breadcrumb_home": "Start", "page_segment": "p"})
site = env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/site", "", nil).Body.String()
if !jsonContains(site, `"home_crumb":{"name":"Start"`) || !jsonContains(site, `"segments":{"en":"p"`) {
t.Fatalf("site answer after the change: %s", site)
}
})
t.Run("refusals", func(t *testing.T) {
refusal(t, env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"page_segment": "Strona!"}), "page_segment", "Use lowercase letters, digits and hyphens only")
refusal(t, env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"page_title_suffix": "– %foo%"}), "page_title_suffix", "Unknown placeholder “%foo%”")
refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"page_form": "hash"}), "page_form", "")
})
}
// TestPagedAlternates: the hreflang alternates of page 2 and later point
// at the same page of each translation.
func TestPagedAlternates(t *testing.T) {
env := bootBlog(t)
resetBlog()
res := env.resolve(t, "/blog/first-post?page=2")
want := `[{"hreflang":"en","url":"https://www.example.test/blog/first-post?page=2"},{"hreflang":"pl","url":"https://www.example.test/pl/blog/pierwszy-wpis?page=2"},{"hreflang":"x-default","url":"https://www.example.test/blog/first-post?page=2"}]`
if got := asJSON(t, res["alternates"]); got != want {
t.Fatalf("paged alternates = %s", got)
}
}