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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user