Files
sm-seo-plugin/structured_data_test.go
Jakub Zych fde5024b5a feat: publisher, local business and home crumb settings
- Publisher and Local business tabs with the C20 refusals (sameAs lines,
  person as local business, address, times, country, coordinates)
- the publisher node from the settings, as an Organization, a Person or
  the chosen local business type with address, geo and opening hours
- P8 publisher preview with Google's required and recommended findings
  (classes/schema Validate)
- the home crumb name on the General tab; every new key in the settings
  API and seo_update_settings
2026-11-04 14:14:59 +01:00

480 lines
22 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)
}
}