Files
sm-seo-plugin/settings_test.go

279 lines
12 KiB
Go

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) {
t.Run("two 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 | Indexing | Addresses | 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 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 | Indexing | Addresses | 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())`, 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)
}