Files
sm-seo-plugin/structured_data_test.go
Jakub Zych 17da132b00 feat: graph validation and custom JSON-LD
- seo_schema (seoschema, E8) with the C17 server rules: invalid JSON with
  line and column, non-objects, 16 KB, depth 8, a literal <script and a
  type outside the kind's list refused; the value re-serialised
- a custom node with a generated @id merges into it (schema.merged save
  notice), other nodes are appended to the graph
- seo_primary_parent (seoparent) for article types; both values stored
  in the entry data on every locale row
- the seo_graph widget action (E9 data), P1 primary_gone and the P2
  schema type and breadcrumb trail rows
- GET /_seo/api/v1/graph and MCP seo_get_graph; seo_set_url_seo and
  apply_batch accept primary_parent and schema

The graph tool lands with the validation: the operations parity test
binds the seo_graph widget action to its tool.
2026-11-04 14:33:10 +01:00

862 lines
41 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)
}
}
// schemaJSON encodes a seo_schema value.
func schemaJSON(t *testing.T, typ string, fields map[string]string, custom string) string {
t.Helper()
return asJSON(t, map[string]any{"type": typ, "fields": fields, "custom": custom})
}
// TestCustomJSONLD covers E8's server rules (UI-SPEC C17, D-44, T-33-24):
// every refusal answers 422 with its schema.* copy, a custom node with a
// generated @id merges into it with the schema.merged notice, a new node
// is appended, and the stored value is re-serialised.
func TestCustomJSONLD(t *testing.T) {
env := bootBlog(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true})
findField(t, env.formSchema(t, token, "acme/pages/pages"), "seo_schema")
findField(t, env.formSchema(t, token, "acme/pages/pages"), "seo_graph")
form := adminAPI("/acme/pages/pages/" + id)
save := func(value string) *httptest.ResponseRecorder {
return env.call(t, http.MethodPut, form, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true, "seo_schema": value})
}
deep := strings.Repeat(`{"a":`, 9) + "1" + strings.Repeat("}", 9)
for _, c := range []struct{ name, value, message string }{
{"invalid", schemaJSON(t, "", nil, `{"@type": "Thing",`), "The custom JSON-LD is not valid JSON: line 1, column"},
{"not an object", schemaJSON(t, "", nil, `42`), "must be an object or a list of objects"},
{"list of scalars", schemaJSON(t, "", nil, `[1, 2]`), "must be an object or a list of objects"},
{"too large", schemaJSON(t, "", nil, `{"description": "`+strings.Repeat("x", 17000)+`"}`), "may be at most 16 KB"},
{"too deep", schemaJSON(t, "", nil, deep), "nested more than 8 levels deep"},
{"script", schemaJSON(t, "", nil, `{"name": "</script><script>alert(1)</script>"}`), "Remove the script tag"},
{"type", schemaJSON(t, "BlogPosting", nil, ""), "This schema type is not available"},
{"price", schemaJSON(t, "Service", map[string]string{"price": "abc"}, ""), "Enter a price such as 150"},
{"currency", schemaJSON(t, "Service", map[string]string{"currency": "ZLOTY"}, ""), "three-letter currency code"},
} {
t.Run(c.name, func(t *testing.T) { refusal(t, save(c.value), "seo_schema", c.message) })
}
page := siteURL + "/pages/about"
custom := `[{"@id": "` + page + `#webpage", "name": "Custom name", "about": "a<b"}, {"@id": "` + page + `#faq", "@type": "FAQPage", "name": "FAQ"}]`
rec := save(schemaJSON(t, "AboutPage", nil, custom))
if rec.Code != http.StatusOK {
t.Fatalf("save = %d %s", rec.Code, rec.Body.String())
}
var merged string
for _, n := range saveNotices(t, rec.Body.Bytes()) {
if n.Field == "seo_schema" {
merged = n.Message
}
}
if merged != "Saved. Your custom JSON-LD replaces about, name in the generated AboutPage node." {
t.Fatalf("merge notice = %q (%s)", merged, rec.Body.String())
}
nodes := graphNodes(t, env.resolve(t, "/pages/about"))
if p := nodes["AboutPage"]; p == nil || p["name"] != "Custom name" || p["about"] != "a<b" || idOf(p["isPartOf"]) != siteURL+"/#website" {
t.Fatalf("merged page node = %v", nodes)
}
if faq := nodes["FAQPage"]; faq == nil || faq["@id"] != page+"#faq" {
t.Fatalf("appended node = %v", nodes)
}
var stored string
if err := env.db.Raw(`SELECT data->>'schema' FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'en'`, pagesModel, id).Scan(&stored).Error; err != nil {
t.Fatal(err)
}
if !strings.Contains(stored, `\u003c`) || strings.Contains(stored, "a<b") || !strings.Contains(stored, `"type":"AboutPage"`) {
t.Fatalf("stored value = %s, want re-serialised JSON with < escaped", stored)
}
var pl string
if err := env.db.Raw(`SELECT COALESCE(data->>'schema', '') FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'pl'`, pagesModel, id).Scan(&pl).Error; err != nil || pl != stored {
t.Fatalf("pl row schema = %q err=%v, want the shared value", pl, err)
}
// The SEO tab loads the stored value; an empty value resets it.
if show := env.expect(t, http.StatusOK, http.MethodGet, form, token, nil).Body.String(); !strings.Contains(show, `"seo_schema":"{`) {
t.Fatalf("show lacks seo_schema: %s", show)
}
env.expect(t, http.StatusOK, http.MethodPut, form, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true, "seo_schema": ""})
if nodes := graphNodes(t, env.resolve(t, "/pages/about")); nodes["WebPage"] == nil || nodes["FAQPage"] != nil {
t.Fatalf("reset graph = %v", nodes)
}
}
// TestGraphPerKind covers the node of every kind (D-43, D-94): a
// collection, a profile, a registry offer with Service fields, a registry
// contact page, and the home page's SearchAction.
func TestGraphPerKind(t *testing.T) {
env := bootBlog(t, "SUMMER_GOLEM15__SEO__SEARCH_URL=/szukaj?q={search_term_string}")
token := env.admin(t, true)
nodes := graphNodes(t, env.resolve(t, "/blog/category/recipes"))
if p := nodes["CollectionPage"]; p == nil || idOf(p["mainEntity"]) != siteURL+"/blog/category/recipes#itemlist" {
t.Fatalf("collection = %v", nodes)
}
nodes = graphNodes(t, env.resolve(t, "/blog/author/jan"))
if p, person := nodes["ProfilePage"], nodes["Person"]; p == nil || person == nil || idOf(p["mainEntity"]) != siteURL+"/blog/author/jan#profile" || person["@id"] != siteURL+"/blog/author/jan#profile" || person["name"] != "Jan Kowalski" {
t.Fatalf("profile = %v", nodes)
}
api := "/_seo/api/v1/pages"
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/oferta", "title": "Warsztaty", "kind": "offer"})
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/kontakt", "title": "Kontakt", "kind": "page"})
set := func(url, value string) {
t.Helper()
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": url, "values": map[string]any{"schema": value}})
}
set("/oferta", schemaJSON(t, "", map[string]string{"serviceType": "Warsztaty grzybowe", "areaServed": "Warszawa, Kraków", "price": "149,99", "currency": "pln"}, ""))
nodes = graphNodes(t, env.resolve(t, "/oferta"))
svc := nodes["Service"]
if svc == nil || idOf(svc["provider"]) != siteURL+"/#organization" || asJSON(t, svc["areaServed"]) != `["Warszawa","Kraków"]` || svc["serviceType"] != "Warsztaty grzybowe" {
t.Fatalf("service = %v", nodes)
}
if got := asJSON(t, svc["offers"]); got != `{"@type":"Offer","price":"149.99","priceCurrency":"PLN","url":"https://www.example.test/oferta"}` {
t.Fatalf("offer = %s", got)
}
set("/kontakt", schemaJSON(t, "ContactPage", nil, ""))
nodes = graphNodes(t, env.resolve(t, "/kontakt"))
if p := nodes["ContactPage"]; p == nil || idOf(p["mainEntity"]) != siteURL+"/#organization" {
t.Fatalf("contact = %v", nodes)
}
// The API refuses what the SEO tab refuses; a rule-only URL takes no
// structured data.
rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/kontakt", "values": map[string]any{"schema": schemaJSON(t, "NewsArticle", nil, "")}})
refusal(t, rec, "schema", "This schema type is not available")
home := graphNodes(t, env.resolve(t, "/"))
action, _ := home["WebSite"]["potentialAction"].(map[string]any)
if action["@type"] != "SearchAction" || asJSON(t, action["target"]) != `{"@type":"EntryPoint","urlTemplate":"https://www.example.test/szukaj?q={search_term_string}"}` {
t.Fatalf("SearchAction = %v", home["WebSite"])
}
for _, n := range []string{"Product", "Offer"} {
if home[n] != nil {
t.Fatalf("e-commerce node %s", n)
}
}
}
// TestGraphTool covers GET /_seo/api/v1/graph (seo_get_graph, D-46,
// D-10, T-33-27): the graph and findings, the unsaved schema preview, the
// permission, and primary_parent through seo_set_url_seo and apply_batch.
func TestGraphTool(t *testing.T) {
env := bootBlog(t)
token := env.admin(t, true)
other := env.admin(t, false)
target := "/_seo/api/v1/graph?url=" + urlQueryEscape("/blog/first-post")
env.expect(t, http.StatusForbidden, http.MethodGet, target, other, nil)
env.expect(t, http.StatusUnauthorized, http.MethodGet, target, "", nil)
var answer struct {
Locale string `json:"locale"`
URL *string `json:"url"`
Graph map[string]any `json:"graph"`
Nodes []struct {
ID string `json:"id"`
Type string `json:"type"`
Rules bool `json:"rules"`
Findings []struct {
Level string `json:"level"`
Property string `json:"property"`
} `json:"findings"`
} `json:"nodes"`
}
body := env.expect(t, http.StatusOK, http.MethodGet, target, token, nil).Body.Bytes()
if err := json.Unmarshal(body, &answer); err != nil || answer.Locale != "en" || answer.URL == nil || *answer.URL != siteURL+"/blog/first-post" || answer.Graph["@context"] != "https://schema.org" {
t.Fatalf("graph answer = %s err=%v", body, err)
}
rules := map[string]bool{}
findings := map[string]string{}
for _, n := range answer.Nodes {
rules[n.Type] = n.Rules
for _, f := range n.Findings {
findings[n.Type+"."+f.Property] = f.Level
}
}
if !rules["BlogPosting"] || rules["WebPage"] || !rules["VideoObject"] || !rules["BreadcrumbList"] {
t.Fatalf("rules = %v", rules)
}
if findings["Organization.logo"] != "recommended" || len(findings) != 1 {
t.Fatalf("findings = %v (%s)", findings, body)
}
// The unsaved schema preview.
preview := target + "&schema=" + urlQueryEscape(schemaJSON(t, "NewsArticle", nil, ""))
if b := env.expect(t, http.StatusOK, http.MethodGet, preview, token, nil).Body.String(); !strings.Contains(b, `"type":"NewsArticle"`) {
t.Fatalf("preview = %s", b)
}
refusal(t, env.call(t, http.MethodGet, target+"&schema="+urlQueryEscape(schemaJSON(t, "ContactPage", nil, "")), token, nil), "schema", "This schema type is not available")
if b := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/graph?url=%2Fnowhere", token, nil).Body.String(); !jsonContains(b, `"url":null`) {
t.Fatalf("missing page = %s", b)
}
// The validator on its own: missing image, uploadDate and address.
resetBlog()
blogMu.Lock()
blogRecords[postTypeKey][0].PublishedAt = nil
blogRecords[postTypeKey][0].Locales[0].Images = nil
blogRecords[postTypeKey][0].Locales[0].Image = ""
blogMu.Unlock()
body = env.expect(t, http.StatusOK, http.MethodGet, target, token, nil).Body.Bytes()
_ = json.Unmarshal(body, &answer)
findings = map[string]string{}
for _, n := range answer.Nodes {
for _, f := range n.Findings {
findings[n.Type+"."+f.Property] = f.Level
}
}
if findings["BlogPosting.image"] != "recommended" || findings["VideoObject.uploadDate"] != "required" || findings["BlogPosting.datePublished"] != "recommended" {
t.Fatalf("findings = %v", findings)
}
resetBlog()
// primary_parent through seo_set_url_seo (every locale row) and
// apply_batch with dry_run.
rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:9"}})
refusal(t, rec, "primary_parent", "")
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:2"}})
var rows int64
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE model_type = ? AND record_id = '1' AND data->>'primary_parent' = 'acme-section:2'`, postModel).Scan(&rows).Error; err != nil || rows != 2 {
t.Fatalf("rows with primary_parent = %d err=%v, want both locales", rows, err)
}
if got := crumbNames(env.resolve(t, "/pl/blog/pierwszy-wpis")); got != "Strona główna > Pierwszy wpis" {
t.Fatalf("pl trail = %s (Mushrooms has no pl page)", got)
}
batch := map[string]any{"dry_run": true, "changes": []map[string]any{{"op": "set_seo", "url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:1"}}}}
env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", token, batch)
if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Mushrooms > First post" {
t.Fatalf("trail after a dry run = %s", got)
}
batch["dry_run"] = false
env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", token, batch)
if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Recipes > First post" {
t.Fatalf("trail after the batch = %s", got)
}
// P1: a stored primary parent the record no longer has.
blogMu.Lock()
blogRecords[postTypeKey][0].Parents = []pact.ContentRef{{Type: categoryTypeKey, ID: "2"}}
blogMu.Unlock()
defer resetBlog()
status, err := env.seoPlugin(t).Service().RecordStatus(t.Context(), postTypeKey, "1")
if err != nil {
t.Fatal(err)
}
found := false
for _, c := range status.Callouts {
found = found || strings.Contains(c.Title, "The primary category “Recipes” is no longer assigned")
}
if !found {
t.Fatalf("P1 callouts = %v", status.Callouts)
}
if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Mushrooms > First post" {
t.Fatalf("trail with a gone primary = %s", got)
}
eff, err := env.seoPlugin(t).Service().RecordEffective(t.Context(), postTypeKey, "1")
if err != nil {
t.Fatal(err)
}
rows2 := map[string]string{}
for _, r := range eff.Locales[0].Rows {
rows2[r.Property] = r.Value
}
if rows2["Schema type"] != "BlogPosting" || rows2["Breadcrumb trail"] != "Home › Mushrooms › First post" {
t.Fatalf("P2 rows = %v", rows2)
}
}