From 17da132b00eef8da43fcad7ed988ab5aec4c8367 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 14:33:10 +0100 Subject: [PATCH] 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 "}`), "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>'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>'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) + } +}