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.
This commit is contained in:
Jakub Zych
2026-11-04 14:33:10 +01:00
parent 032d3dae45
commit 17da132b00
19 changed files with 1259 additions and 16 deletions

View File

@@ -598,3 +598,264 @@ func TestPagedAlternates(t *testing.T) {
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)
}
}