package seo_test import ( "context" "encoding/json" "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, " > ") }