- 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.
862 lines
41 KiB
Go
862 lines
41 KiB
Go
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)
|
||
}
|
||
}
|