test: unit tests for the SEO classes

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).
This commit is contained in:
2026-10-27 13:21:38 +01:00
parent 6d54fc7001
commit 9e45ddc848
11 changed files with 1275 additions and 1 deletions

149
classes/issues_test.go Normal file
View File

@@ -0,0 +1,149 @@
package classes
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/models"
)
// TestIssues: each kind of the CMS-data issue report from loaded data
// (the database reads of Issues are covered by TestIssues in the plugin
// package).
func TestIssues(t *testing.T) {
svc := testServiceWithPages(t, "https://example.test",
testPage{id: "1", slugs: map[string]string{"en": "a"}, published: true},
testPage{id: "2", slugs: map[string]string{"en": "b"}, published: true},
)
entry := func(url, locale, title, desc string) models.Entry {
return models.Entry{URL: url, Locale: locale, EffectiveTitle: title, EffectiveDescription: desc}
}
canonicals := map[string][2]string{
"/noindexed": {IssueCanonicalNoindex, ""},
"/moved": {IssueCanonicalRedirected, "/pages/a"},
}
canonical := func(_ context.Context, value string) (string, string, error) {
if value == "/boom" {
return "", "", errors.New("resolver down")
}
got := canonicals[value]
return got[0], got[1], nil
}
data := issueData{
locale: "en",
entries: []models.Entry{
func() models.Entry {
e := entry("/pages/a", "en", "Same Title", "First")
e.Canonical = "/moved"
return e
}(),
entry("/pages/b", "en", "same title ", ""),
entry("/pages/c", "en", "", "Shared description"),
entry("/pages/c", "pl", "Same Title", "Shared description"),
entry("/pages/d", "en", "Unique", "shared DESCRIPTION"),
},
rules: []models.Rule{
{ID: 7, URL: "/pages/a", Match: models.MatchExact, Robots: "noindex"},
{ID: 8, URL: "/landing", Match: models.MatchExact, Title: "Unique", Canonical: "/noindexed"},
{ID: 9, URL: "/pages/*", Match: models.MatchPattern, Title: "Same Title"},
{ID: 10, URL: "/pages/b?x=1", Match: models.MatchExact, Title: "Query rule"},
{ID: 11, URL: "/pages/b", Match: models.MatchExact},
},
redirects: []models.Redirect{rd(1, "/old1", "/old2"), rd(2, "/old2", "/pages/a"), rd(3, "/direct", "/pages/a")},
robots: models.Robots{Content: "User-agent: *\nDisallow: /pages/a\n"},
noindex: []string{"/pages/a", "/landing"},
}
issues, err := svc.issuesOf(t.Context(), data, canonical)
if err != nil {
t.Fatal(err)
}
got := map[string]Issue{}
var order []string
for _, is := range issues {
key := is.Kind + " " + is.URL + " " + is.Locale
if _, dup := got[key]; dup {
t.Fatalf("issue listed twice: %s", key)
}
got[key] = is
order = append(order, key)
}
want := []string{
"canonical_noindex /landing ",
"canonical_redirected /pages/a en",
"duplicate_description /pages/c en",
"duplicate_description /pages/d en",
"duplicate_title /landing ",
"duplicate_title /pages/a en",
"duplicate_title /pages/b en",
"duplicate_title /pages/d en",
"missing_description /pages/b en",
"missing_title /pages/c en",
"noindex_blocked /pages/a ",
"redirect_chain /old1 ",
"rule_overrides_record /pages/a en",
}
if !reflect.DeepEqual(order, want) {
t.Fatalf("issues (sorted by kind, url, locale):\n%v\nwant\n%v", order, want)
}
tools := map[string]string{
"canonical_noindex /landing ": "seo_update_rule", "canonical_redirected /pages/a en": "seo_set_url_seo",
"missing_title /pages/c en": "seo_set_url_seo", "noindex_blocked /pages/a ": "seo_update_robots",
"redirect_chain /old1 ": "seo_update_redirect", "rule_overrides_record /pages/a en": "seo_update_rule",
}
for key, tool := range tools {
if got[key].Tool != tool {
t.Errorf("%s tool = %s, want %s", key, got[key].Tool, tool)
}
}
if d := got["duplicate_title /pages/a en"].Details; d["value"] != "same title" || fmt.Sprint(d["also"]) != "[/pages/b]" {
t.Errorf("duplicate_title details = %v", d)
}
if d := got["duplicate_title /landing "].Details; fmt.Sprint(d["also"]) != "[/pages/d]" {
t.Errorf("a rule's title joins the default locale's duplicates: %v", d)
}
if d := got["rule_overrides_record /pages/a en"].Details; d["rule_id"] != uint(7) || d["type"] != "acme-page" || d["id"] != "1" || fmt.Sprint(d["fields"]) != "[robots]" {
t.Errorf("rule_overrides_record details = %v", d)
}
if d := got["redirect_chain /old1 "].Details; d["hops"] != 2 || d["final"] != "/pages/a" || fmt.Sprint(d["path"]) != "[/old1 /old2 /pages/a]" {
t.Errorf("redirect_chain details = %v", d)
}
if d := got["canonical_redirected /pages/a en"].Details; d["canonical"] != "/moved" || d["target"] != "/pages/a" {
t.Errorf("canonical_redirected details = %v", d)
}
if d := got["canonical_noindex /landing "].Details; d["rule_id"] != uint(8) || d["canonical"] != "/noindexed" {
t.Errorf("canonical_noindex details = %v", d)
}
t.Run("a clean site has no issues", func(t *testing.T) {
clean := issueData{locale: "en", entries: []models.Entry{entry("/pages/a", "en", "A", "About A"), entry("/pages/b", "en", "B", "About B")}}
if issues, err := svc.issuesOf(t.Context(), clean, canonical); err != nil || len(issues) != 0 {
t.Fatalf("issues = %+v, %v", issues, err)
}
})
t.Run("the same text in two locales is not a duplicate", func(t *testing.T) {
two := issueData{locale: "en", entries: []models.Entry{entry("/pages/a", "en", "Name", "Text"), entry("/pl/pages/a", "pl", "Name", "Text")}}
if issues, _ := svc.issuesOf(t.Context(), two, canonical); len(issues) != 0 {
t.Fatalf("issues = %+v", issues)
}
})
t.Run("a canonical check that fails stops the report", func(t *testing.T) {
broken := issueData{locale: "en", entries: []models.Entry{func() models.Entry { e := entry("/pages/a", "en", "A", "B"); e.Canonical = "/boom"; return e }()}}
if _, err := svc.issuesOf(t.Context(), broken, canonical); err == nil {
t.Fatal("the resolver error was dropped")
}
})
t.Run("canonicals on other sites and empty canonicals are not checked", func(t *testing.T) {
for _, value := range []string{"", " ", "https://other.test/a"} {
kind, target, err := svc.canonicalProblem(t.Context(), value)
if kind != "" || target != "" || err != nil {
t.Errorf("canonicalProblem(%q) = %q %q %v", value, kind, target, err)
}
}
})
}