package seo_test import ( "encoding/json" "net/http" "strings" "testing" ) // tabsOf returns the schema's tab labels in first-appearance order. func tabsOf(fields []schemaField) []string { var out []string seen := map[string]bool{} for _, f := range fields { if !seen[f.Tab] { seen[f.Tab] = true out = append(out, f.Tab) } } return out } // TestSettingsTypeTabs checks the D-104 per-type tabs on the settings // singleton: one tab per content type in registration order between // General and Indexing, their values in golem15_seo_type_settings with the // D-21 defaults, template checks, and the inject switch hiding the SEO tab. func TestSettingsTypeTabs(t *testing.T) { // The SEO plugin's own page registry (seo-page, "Site page") is a // content type too, registered after the fixture's. t.Run("three content types", func(t *testing.T) { env := bootEnv(t) token := env.admin(t, true) fields := env.formSchema(t, token, "golem15/seo/settings") if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Pagination | Links | Publisher | Local business | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } findField(t, fields, "type_acme_page_inject") findField(t, fields, "type_acme_tag_robots") show := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/1"), token, nil) var body struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(show.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body.Data["type_acme_page_inject"] != true || body.Data["type_acme_tag_robots"] != "noindex,follow" || body.Data["type_acme_page_robots"] != "" { t.Fatalf("per-type defaults = %v", body.Data) } rec := env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{ "type_acme_page_title_template": "%title% - %nope%", }) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "type_acme_page_title_template") || !strings.Contains(rec.Body.String(), "%nope%") { t.Fatalf("unknown placeholder = %d %s, want 422 on the type field naming %%nope%%", rec.Code, rec.Body.String()) } id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Stored"}}) env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{ "type_acme_page_inject": false, "type_acme_page_title_template": "%title% · Pages", }) var row struct { Inject bool TitleTemplate string } if err := env.db.Raw(`SELECT inject, title_template FROM golem15_seo_type_settings WHERE type_key = ?`, pagesTypeKey).Scan(&row).Error; err != nil || row.Inject || row.TitleTemplate != "%title% · Pages" { t.Fatalf("type settings row = %+v err=%v", row, err) } for _, f := range env.formSchema(t, token, "acme/pages/pages") { if strings.HasPrefix(f.Name, "seo_") { t.Fatalf("SEO tab field %s shown with inject off", f.Name) } } got := env.resolve(t, "/pages/about") if seoBody, _ := got["seo"].(map[string]any); seoBody["title"] != "Stored" { t.Fatalf("resolve with the tab off = %v, want the stored title (id %s)", got, id) } }) t.Run("no other content types", func(t *testing.T) { env := bootPlugins(t, []string{"golem15.seo"}) token := env.admin(t, true) if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Pagination | Links | Publisher | Local business | Verification"; got != want { t.Fatalf("settings tabs = %q, want %q", got, want) } }) } // TestResolverPrecedence covers each P-14 chain of the resolver: the value // and its source per case, with URL rules (exact and pattern), records, // per-type templates and defaults, content, the settings, the D-07 // parameter list, the never-indexed and search paths, the home page and // the non-production lock. func TestResolverPrecedence(t *testing.T) { env := bootEnv(t) token := env.admin(t, true) env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{ "site_name": map[string]any{"en": "Acme", "pl": "Akme"}, "default_description": map[string]any{"en": "Site default description"}, "default_og_image": "/media/default.png", "twitter_site": "@acme", "verify_google": "g-code", "noindex_paths": "/konto/*\n/logowanie", "search_paths": "/szukaj", "query_params": []map[string]any{ {"name": "page", "kind": "content", "path": ""}, {"name": "utm_*", "kind": "tracking", "path": ""}, {"name": "sort", "kind": "filter", "path": ""}, {"name": "color", "kind": "filter", "path": "/pages/*"}, }, "type_acme_tag_description_template": "Posts tagged %title%", }) about := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "excerpt": "Who we are", "published": true}) custom := env.createPage(t, token, map[string]any{"title": "Custom", "slug": "custom", "excerpt": "Custom excerpt", "published": true}) env.createPage(t, token, map[string]any{"title": "Bare", "slug": "bare", "published": true}) env.createPage(t, token, map[string]any{"title": "Draft", "slug": "draft", "excerpt": "Secret", "published": false}) if err := env.db.Exec(`INSERT INTO golem15_seo_entries (model_type, record_id, locale, type_key, title, description, canonical, robots, og_image, created_at, updated_at) VALUES (?, ?, 'en', ?, 'Custom SEO title', 'Custom SEO description', '/canonical-custom', 'noindex', '/media/custom.png', NOW(), NOW()) ON CONFLICT (model_type, record_id, locale) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, canonical = EXCLUDED.canonical, robots = EXCLUDED.robots, og_image = EXCLUDED.og_image`, pagesModel, custom, pagesTypeKey).Error; err != nil { t.Fatal(err) } for _, rule := range []map[string]any{ {"url": "/pages/custom", "match": "exact", "og_title": "Rule social title"}, {"url": "/blog?tag=*", "match": "pattern", "title": "Tag archive", "robots": "noindex,follow"}, {"url": "/landing", "match": "exact", "title": "Landing", "description": "Landing description", "canonical": "https://other.example/landing", "og_image": "https://cdn.example/landing.png"}, {"url": "/konto/profile", "match": "exact", "title": "Profile"}, {"url": "/szukaj", "match": "exact", "title": "Search"}, {"url": "/pages/about", "match": "exact", "robots": "max-snippet:50"}, } { env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, rule) } _ = about resolver := env.seoPlugin(t).Service().Resolver() type expectation struct { value, source string } cases := []struct { name string url string want map[string]expectation // title, description, canonical, robots, og_title, og_description, og_image }{ {"title from the general template, description from content, self canonical, default robots", "/pages/about", map[string]expectation{ "title": {"About | Acme", "template"}, "description": {"Who we are", "content"}, "canonical": {siteURL + "/pages/about", "default"}, "robots": {"index, follow, max-snippet:50", "default"}, "og_title": {"About | Acme", "template"}, "og_image": {siteURL + "/media/default.png", "global"}, }}, {"record values win over templates; a rule wins over the record", "/pages/custom", map[string]expectation{ "title": {"Custom SEO title", "record"}, "description": {"Custom SEO description", "record"}, "canonical": {siteURL + "/canonical-custom", "record"}, "robots": {"noindex, follow", "record"}, "og_title": {"Rule social title", "rule"}, "og_description": {"Custom SEO description", "record"}, "og_image": {siteURL + "/media/custom.png", "record"}, }}, {"description falls back to the settings", "/pages/bare", map[string]expectation{ "description": {"Site default description", "global"}, }}, {"type description template and the D-21 tag default", "/pages/tag/go", map[string]expectation{ "title": {"Tag go | Acme", "template"}, "description": {"Posts tagged Tag go", "template"}, "robots": {"noindex, follow", "template"}, }}, {"pattern rule on a URL without a record", "/blog?tag=go", map[string]expectation{ "title": {"Tag archive", "rule"}, "robots": {"noindex, follow", "rule"}, }}, {"rule-only page with an absolute canonical on another domain", "/landing", map[string]expectation{ "title": {"Landing", "rule"}, "description": {"Landing description", "rule"}, "canonical": {"https://other.example/landing", "rule"}, "og_image": {"https://cdn.example/landing.png", "rule"}, }}, {"tracking dropped, content parameter kept", "/pages/about?utm_source=news&page=2", map[string]expectation{ "canonical": {siteURL + "/pages/about?page=2", "default"}, "robots": {"index, follow, max-snippet:50", "default"}, }}, {"a filter parameter: dropped and noindex,follow", "/pages/about?sort=price&page=2", map[string]expectation{ "canonical": {siteURL + "/pages/about?page=2", "default"}, "robots": {"noindex, follow, max-snippet:50", "global"}, }}, {"a filter parameter limited to a path", "/pages/bare?color=red", map[string]expectation{ "canonical": {siteURL + "/pages/bare", "default"}, "robots": {"noindex, follow", "global"}, }}, {"never-indexed path", "/konto/profile", map[string]expectation{ "robots": {"noindex, nofollow", "global"}, }}, {"internal search path", "/szukaj", map[string]expectation{ "robots": {"noindex, follow", "global"}, }}, {"home page template", "/", map[string]expectation{ "title": {"Acme", "template"}, "description": {"Site default description", "global"}, }}, {"translated site name", "/pl/pages/about", map[string]expectation{ "title": {"About | Akme", "template"}, }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { res, err := resolver.Resolve(t.Context(), tc.url) if err != nil { t.Fatal(err) } if res.Action != "ok" || res.SEO == nil { t.Fatalf("Resolve(%s) = %+v, want ok", tc.url, res) } got := map[string]string{ "title": res.SEO.Title, "description": res.SEO.Description, "canonical": res.SEO.Canonical, "robots": res.SEO.Robots, "og_title": res.SEO.OG.Title, "og_description": res.SEO.OG.Description, "og_image": res.SEO.OG.Image, } for name, want := range tc.want { if got[name] != want.value || res.Sources[name] != want.source { t.Errorf("%s = %q (%s), want %q (%s)", name, got[name], res.Sources[name], want.value, want.source) } } if res.SEO.XRobotsTag != res.SEO.Robots { t.Errorf("x_robots_tag %q differs from robots %q", res.SEO.XRobotsTag, res.SEO.Robots) } if res.Staging { t.Error("staging true in production") } }) } t.Run("social and verification values", func(t *testing.T) { got := env.resolve(t, "/") seoBody := got["seo"].(map[string]any) verification, _ := seoBody["verification"].([]any) if len(verification) != 1 || !strings.Contains(fmtJSON(verification), `"google-site-verification"`) || !strings.Contains(fmtJSON(verification), `"g-code"`) { t.Fatalf("home verification = %v", verification) } got = env.resolve(t, "/pages/about") seoBody = got["seo"].(map[string]any) if v, _ := seoBody["verification"].([]any); len(v) != 0 { t.Fatalf("verification on a non-home page = %v", v) } twitter := seoBody["twitter"].(map[string]any) og := seoBody["og"].(map[string]any) if twitter["card"] != "summary_large_image" || twitter["site"] != "@acme" || og["url"] != siteURL+"/pages/about" { t.Fatalf("social = %v %v", twitter, og) } }) t.Run("drafts and unknown paths are not found", func(t *testing.T) { for _, u := range []string{"/pages/draft", "/pages/nope", "/nowhere"} { res, err := resolver.Resolve(t.Context(), u) if err != nil || res.Action != "not_found" || res.Status != 404 || res.SEO != nil { t.Fatalf("Resolve(%s) = %+v err=%v, want not_found without SEO", u, res, err) } } rec := env.call(t, http.MethodGet, "/_seo/api/v1/resolve?url=javascript:alert(1)", "", nil) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("bad url = %d, want 422", rec.Code) } }) t.Run("staging lock", func(t *testing.T) { staging := bootEnv(t, "SUMMER_ENV=staging") stoken := staging.admin(t, true) staging.createPage(t, stoken, map[string]any{"title": "About", "slug": "about", "published": true}) got := staging.resolve(t, "/pages/about") seoBody := got["seo"].(map[string]any) if got["staging"] != true || seoBody["robots"] != "noindex, nofollow" || seoBody["x_robots_tag"] != "noindex, nofollow" { t.Fatalf("staging resolve = %v", got) } }) } // fmtJSON encodes v for substring checks. func fmtJSON(v any) string { raw, _ := json.Marshal(v) return string(raw) }