Pure tests of the classes package (no database): the resolver's value chains with every layer present and absent, robots per directive, the staging lock and drafts; the redirect graph (chains, flattened and back-and-forth graphs, loops by exact, pattern and regex probes, the 10-hop cap); URL normalisation across every address rule; the robots grammar over every directive combination; Google's robots.txt matcher examples; templates and unknown placeholders; CSV export and import; owner resolution; and every kind of the issue report. go.mod: gorm.io/driver/postgres becomes a direct requirement (the tests' dry-run database).
161 lines
7.6 KiB
Go
161 lines
7.6 KiB
Go
package classes
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
)
|
|
|
|
// layer is one layer of a value chain: its source and the value it holds
|
|
// when present.
|
|
type layer struct{ source, value string }
|
|
|
|
// TestResolverPrecedence: every value chain of the resolver (P-14) with
|
|
// each layer present and absent picks the first present layer and reports
|
|
// its source; robots merge per directive; outside production the lock
|
|
// wins; a draft is not found. The Postgres-backed answer with stored rules
|
|
// and entries is TestResolverPrecedence in the plugin package.
|
|
func TestResolverPrecedence(t *testing.T) {
|
|
chains := map[string][]layer{
|
|
// rule > record > type or general template > content > site name
|
|
"title": {{SourceRule, "Rule title"}, {SourceRecord, "Record title"}, {SourceTemplate, "Post | Site"}, {SourceContent, "Post"}, {SourceGlobal, "Site"}},
|
|
// rule > record > type template > content > default description
|
|
"description": {{SourceRule, "Rule desc"}, {SourceRecord, "Record desc"}, {SourceTemplate, "Template desc"}, {SourceContent, "Excerpt"}, {SourceGlobal, "Default desc"}},
|
|
// rule > record (else the address itself)
|
|
"canonical": {{SourceRule, "/rule"}, {SourceRecord, "/record"}},
|
|
// override rule > record > the effective title
|
|
"og_title": {{SourceRule, "OG rule"}, {SourceRecord, "OG record"}, {SourceTemplate, "Effective title"}},
|
|
// override rule > record > the effective description
|
|
"og_description": {{SourceRule, "OG rule"}, {SourceRecord, "OG record"}, {SourceContent, "Effective description"}},
|
|
// rule > record > content image > default social image
|
|
"og_image": {{SourceRule, "/r.png"}, {SourceRecord, "/e.png"}, {SourceContent, "/c.png"}, {SourceGlobal, "/d.png"}},
|
|
}
|
|
for name, chain := range chains {
|
|
t.Run(name, func(t *testing.T) {
|
|
for mask := 0; mask < 1<<len(chain); mask++ {
|
|
cands := make([]candidate, len(chain))
|
|
wantValue, wantSource := "", SourceDefault
|
|
for i, l := range chain {
|
|
if mask&(1<<i) != 0 {
|
|
cands[i] = candidate{" " + l.value + " ", l.source}
|
|
if wantValue == "" {
|
|
wantValue, wantSource = l.value, l.source
|
|
}
|
|
} else {
|
|
cands[i] = candidate{" \t", l.source}
|
|
}
|
|
}
|
|
if value, source := pick(cands...); value != wantValue || source != wantSource {
|
|
t.Fatalf("layers %0*b: pick = %q from %s, want %q from %s", len(chain), mask, value, source, wantValue, wantSource)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("the first URL rule with a value wins", func(t *testing.T) {
|
|
rules := []models.Rule{{URL: "/a", Title: " "}, {URL: "/a*", Title: "Pattern"}, {URL: "/*", Title: "Wide"}}
|
|
if got := ruleValue(rules, "title"); got != "Pattern" {
|
|
t.Fatalf("ruleValue = %q", got)
|
|
}
|
|
if got := ruleValue(rules, "description"); got != "" {
|
|
t.Fatalf("ruleValue of an unset column = %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("robots per directive", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
layers []robotsLayer
|
|
want string
|
|
source string
|
|
}{
|
|
{"no layer: index, follow", nil, "index, follow", SourceDefault},
|
|
{"the rule decides", []robotsLayer{{"noindex", SourceRule}, {"index,nofollow", SourceRecord}}, "noindex, nofollow", SourceRule},
|
|
{"the record decides", []robotsLayer{{"", SourceRule}, {"noindex", SourceRecord}, {"index", SourceTemplate}}, "noindex, follow", SourceRecord},
|
|
{"never-indexed paths", []robotsLayer{{"", SourceRule}, {"", SourceRecord}, {"noindex,nofollow", SourceGlobal}}, "noindex, nofollow", SourceGlobal},
|
|
{"the type default", []robotsLayer{{"", SourceRule}, {"", SourceRecord}, {"noindex,max-snippet:20", SourceTemplate}}, "noindex, follow, max-snippet:20", SourceTemplate},
|
|
{"a layer without index does not decide", []robotsLayer{{"nofollow", SourceRule}, {"noarchive", SourceRecord}}, "index, nofollow, noarchive", SourceDefault},
|
|
{"directives merge across layers", []robotsLayer{{"max-snippet:5", SourceRule}, {"noindex,max-snippet:50,max-image-preview:large", SourceRecord}}, "noindex, follow, max-snippet:5, max-image-preview:large", SourceRecord},
|
|
{"a malformed layer is skipped", []robotsLayer{{"noindex,noindex", SourceRule}, {"noindex", SourceRecord}}, "noindex, follow", SourceRecord},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r, source := resolveRobots(tc.layers)
|
|
if r.Header() != tc.want || source != tc.source {
|
|
t.Fatalf("resolveRobots = %q from %s, want %q from %s", r.Header(), source, tc.want, tc.source)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
pages := []testPage{
|
|
{id: "1", slugs: map[string]string{"en": "about"}, published: true},
|
|
{id: "2", slugs: map[string]string{"en": "draft"}, published: false},
|
|
}
|
|
db := dryDB(t)
|
|
|
|
t.Run("a published record without stored SEO", func(t *testing.T) {
|
|
svc := testServiceWithPages(t, "https://example.test", pages...)
|
|
res, err := svc.Resolver().Resolve(WithTx(t.Context(), db), "/pages/about")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Action != ActionOK || res.Status != 200 || res.Staging || res.TypeKey != "acme-page" || res.RecordID != "1" {
|
|
t.Fatalf("result = %+v", res)
|
|
}
|
|
seo := res.SEO
|
|
// The general template on the content title; no site name, so the
|
|
// separator is trimmed.
|
|
if seo.Title != "Page 1" || res.Sources["title"] != SourceTemplate {
|
|
t.Fatalf("title = %q from %s", seo.Title, res.Sources["title"])
|
|
}
|
|
if seo.Description != "" || res.Sources["description"] != SourceDefault {
|
|
t.Fatalf("description = %q from %s", seo.Description, res.Sources["description"])
|
|
}
|
|
if seo.Canonical != "https://example.test/pages/about" || res.Sources["canonical"] != SourceDefault {
|
|
t.Fatalf("canonical = %q from %s", seo.Canonical, res.Sources["canonical"])
|
|
}
|
|
if seo.Robots != "index, follow" || seo.XRobotsTag != "index, follow" || res.Sources["robots"] != SourceDefault {
|
|
t.Fatalf("robots = %q from %s", seo.Robots, res.Sources["robots"])
|
|
}
|
|
if seo.OG.Title != "Page 1" || res.Sources["og_title"] != SourceTemplate || seo.OG.URL != seo.Canonical || seo.Twitter.Card != "summary" {
|
|
t.Fatalf("social = %+v %v", seo.OG, res.Sources)
|
|
}
|
|
})
|
|
|
|
t.Run("the staging lock", func(t *testing.T) {
|
|
svc := testService(t, "staging", "https://example.test")
|
|
res, err := svc.Resolver().Resolve(WithTx(t.Context(), db), "/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.Staging || res.SEO.Robots != "noindex, nofollow" || res.SEO.XRobotsTag != "noindex, nofollow" || res.Sources["robots"] != SourceDefault {
|
|
t.Fatalf("staging = %v, robots %q from %s", res.Staging, res.SEO.Robots, res.Sources["robots"])
|
|
}
|
|
prod, err := testService(t, "production", "https://example.test").Resolver().Resolve(WithTx(t.Context(), db), "/")
|
|
if err != nil || prod.Staging || prod.SEO.Robots != "index, follow" {
|
|
t.Fatalf("production home = %+v, %v", prod, err)
|
|
}
|
|
})
|
|
|
|
t.Run("a draft is not found", func(t *testing.T) {
|
|
svc := testServiceWithPages(t, "https://example.test", pages...)
|
|
for _, path := range []string{"/pages/draft", "/pages/missing", "/elsewhere"} {
|
|
res, err := svc.Resolver().Resolve(WithTx(t.Context(), db), path)
|
|
if err != nil || res.Action != ActionNotFound || res.Status != 404 || res.SEO != nil {
|
|
t.Fatalf("%s = %+v, %v", path, res, err)
|
|
}
|
|
}
|
|
if res, err := svc.Resolver().Resolve(WithTx(t.Context(), db), "/"); err != nil || res.Action != ActionOK {
|
|
t.Fatalf("the home page without a record = %+v, %v", res, err)
|
|
}
|
|
})
|
|
|
|
t.Run("a record's address in another form moves in one hop", func(t *testing.T) {
|
|
svc := testServiceWithPages(t, "https://example.test", pages...)
|
|
res, err := svc.Resolver().Resolve(WithTx(t.Context(), db), "/pages//about/index.html")
|
|
if err != nil || res.Action != ActionRedirect || res.Status != 301 || res.Location != "https://example.test/pages/about" {
|
|
t.Fatalf("result = %+v, %v", res, err)
|
|
}
|
|
})
|
|
}
|