Files
sm-seo-plugin/classes/redirect_graph_test.go
Jakub Zych 9e45ddc848 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).
2026-10-27 13:21:38 +01:00

185 lines
7.9 KiB
Go

package classes
import (
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/models"
)
// rd is an exact 301 rule with defaults.
func rd(id uint, source, target string) models.Redirect {
r := models.NewRedirect()
r.ID, r.Source, r.Target = id, source, target
return r
}
func rdMatch(id uint, match, source, target string) models.Redirect {
r := rd(id, source, target)
r.Match = match
return r
}
const rdSite = "https://example.test"
// TestRedirectGraph: the redirect graph rules of D-18/D-19 over rule lists
// (the database writes of AddressChanged are covered by TestSlugChange301
// and TestAddressChangeFromEvent in the plugin package).
func TestRedirectGraph(t *testing.T) {
t.Run("a chain A to B to C is a chain notice from A to C", func(t *testing.T) {
stored := []models.Redirect{rd(1, "/b", "/c")}
check, err := checkRedirectRules(rdSite, stored, rd(0, "/a", "/b"))
if err != nil || check.Loop || !check.Chain || check.Hops != 2 || check.Source != "/a" || check.Final != "/c" || check.PathText() != "/a → /b → /c" {
t.Fatalf("check = %+v, %v", check, err)
}
notice := ChainNotice(check)
if len(notice) != 1 || notice[0].Params["count"] != "2" || notice[0].Params["final"] != "/c" || notice[0].Params["source"] != "/a" {
t.Fatalf("notice = %+v", notice)
}
})
t.Run("a rule that another rule leads into is the second hop of a chain", func(t *testing.T) {
stored := []models.Redirect{rd(1, "/a", "/b")}
check, err := checkRedirectRules(rdSite, stored, rd(0, "/b", "/c"))
if err != nil || !check.Chain || check.Hops != 2 || check.Source != "/a" || check.Final != "/c" {
t.Fatalf("check = %+v, %v", check, err)
}
})
t.Run("the flattened graph A to C, B to C is one hop each", func(t *testing.T) {
set := NewRedirectSet([]models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")})
for _, from := range []string{"/a", "/b"} {
ch, err := set.Follow(rdSite, from, "", MaxRedirectHops)
if err != nil || len(ch.Hops) != 1 || ch.Final != "/c" || ch.Loop {
t.Fatalf("%s: chain = %+v, %v", from, ch, err)
}
}
for _, r := range []models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")} {
others := []models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")}
if check, err := checkRedirectRules(rdSite, others, r); err != nil || check.Chain || check.Loop {
t.Fatalf("%s: check = %+v, %v", r.Source, check, err)
}
}
})
t.Run("back and forth A to B to A to C ends as A to C and B to C", func(t *testing.T) {
// After A→B, B→A (the address returned) and then A→C, the graph
// AddressChanged leaves is A→C and B→C: no rule shadows the live
// address and every old address reaches C in one hop.
set := NewRedirectSet([]models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")})
for _, from := range []string{"/a", "/b"} {
if ch, _ := set.Follow(rdSite, from, "", MaxRedirectHops); len(ch.Hops) != 1 || ch.Final != "/c" {
t.Fatalf("%s: chain = %+v", from, ch)
}
}
if _, ok, _ := set.Match("/c", ""); ok {
t.Fatal("the live address is shadowed")
}
// Back and forth without the cleanup would be a loop.
check, err := checkRedirectRules(rdSite, []models.Redirect{rd(1, "/a", "/b")}, rd(0, "/b", "/a"))
if err != nil || !check.Loop {
t.Fatalf("A→B then B→A = %+v, %v; want a loop", check, err)
}
})
loops := []struct {
name string
stored []models.Redirect
rule models.Redirect
path string
}{
{"exact self loop", nil, rd(0, "/a", "/a"), "/a → /a"},
{"exact two-rule loop", []models.Redirect{rd(1, "/b", "/a")}, rd(0, "/a", "/b"), "/a → /b → /a"},
{"exact three-rule loop", []models.Redirect{rd(1, "/b", "/c"), rd(2, "/c", "/a")}, rd(0, "/a", "/b"), "/a → /b → /c → /a"},
{"exact loop through the site's absolute URL", []models.Redirect{rd(1, "/b", rdSite+"/a")}, rd(0, "/a", "/b"), "/a → /b → /a"},
{"exact loop through case and trailing slash", []models.Redirect{rd(1, "/b", "/A/")}, func() models.Redirect { r := rd(0, "/a", "/b"); r.IgnoreCase = true; return r }(), ""},
{"pattern probe loop", []models.Redirect{rd(1, "/new/seoprobe", "/old/x")}, rdMatch(0, models.RedirectPattern, "/old/*", "/new/*"), ""},
{"pattern onto itself", nil, rdMatch(0, models.RedirectPattern, "/docs/*", "/docs/*"), ""},
{"regex probe loop", []models.Redirect{rdMatch(1, models.RedirectPattern, "/b/*", "/a/*")}, rdMatch(0, models.RedirectRegex, `^/a/(\d+)$`, "/b/$1"), `^/a/(\d+)$ → /b/0 → /a/0`},
{"regex onto itself", nil, rdMatch(0, models.RedirectRegex, `^/x/([a-z]+)$`, "/x/$1"), ""},
}
for _, tc := range loops {
t.Run("loop refused: "+tc.name, func(t *testing.T) {
check, err := checkRedirectRules(rdSite, tc.stored, tc.rule)
if err != nil || !check.Loop {
t.Fatalf("check = %+v, %v; want a loop", check, err)
}
if tc.path != "" && check.PathText() != tc.path {
t.Fatalf("path = %q, want %q", check.PathText(), tc.path)
}
if msg := (&LoopError{Check: check}).Error(); !strings.Contains(msg, "loop") {
t.Fatalf("LoopError = %q", msg)
}
})
}
t.Run("a 410 rule ends every chain and is never a loop", func(t *testing.T) {
gone := rd(0, "/a", "")
gone.Status = models.Status410
if check, err := checkRedirectRules(rdSite, []models.Redirect{rd(1, "/b", "/a")}, gone); err != nil || check.Loop || check.Chain {
t.Fatalf("check = %+v, %v", check, err)
}
set := NewRedirectSet([]models.Redirect{rd(1, "/b", "/a"), func() models.Redirect { g := gone; g.ID = 2; return g }()})
ch, err := set.Follow(rdSite, "/b", "", MaxRedirectHops)
if err != nil || !ch.Gone || ch.Loop || len(ch.Hops) != 2 {
t.Fatalf("chain = %+v, %v", ch, err)
}
})
t.Run("an external target ends the chain", func(t *testing.T) {
set := NewRedirectSet([]models.Redirect{rd(1, "/a", "https://other.test/a"), rd(2, "/b", "/a")})
ch, err := set.Follow(rdSite, "/b", "", MaxRedirectHops)
if err != nil || ch.Loop || len(ch.Hops) != 2 || ch.Final != "https://other.test/a" {
t.Fatalf("chain = %+v, %v", ch, err)
}
})
t.Run("the runtime stops after 10 hops", func(t *testing.T) {
var rules []models.Redirect
for i := 0; i < 15; i++ {
rules = append(rules, rd(uint(i+1), "/p"+strconv.Itoa(i), "/p"+strconv.Itoa(i+1)))
}
set := NewRedirectSet(rules)
ch, err := set.Follow(rdSite, "/p0", "", MaxRedirectHops)
if err != nil || !ch.Loop || len(ch.Hops) != MaxRedirectHops || MaxRedirectHops != 10 {
t.Fatalf("chain = %d hops, loop %v, %v", len(ch.Hops), ch.Loop, err)
}
// Reaching the cap counts as a loop; a chain one hop shorter ends.
capped, _ := set.Follow(rdSite, "/p5", "", MaxRedirectHops)
if !capped.Loop || len(capped.Hops) != 10 {
t.Fatalf("ten hops: %d hops, loop %v", len(capped.Hops), capped.Loop)
}
short, err := set.Follow(rdSite, "/p6", "", MaxRedirectHops)
if err != nil || short.Loop || len(short.Hops) != 9 || short.Final != "/p15" {
t.Fatalf("nine hops: %d hops, loop %v, final %s", len(short.Hops), short.Loop, short.Final)
}
// A new rule that makes a chain longer than the cap is a loop.
check, err := checkRedirectRules(rdSite, rules[1:], rd(0, "/p0", "/p1"))
if err != nil || !check.Loop {
t.Fatalf("an 11-hop chain = %+v, %v; want refused", check, err)
}
})
t.Run("probes", func(t *testing.T) {
for _, tc := range []struct {
rule models.Redirect
path string
ok bool
}{
{rd(0, "/a?x=1", "/b"), "/a", true},
{rdMatch(0, models.RedirectPattern, "/old/*/x", "/n"), "/old/seoprobe/x", true},
{rdMatch(0, models.RedirectRegex, `^/a/[0-9]{2}/(x|y)$`, "/n"), "/a/00/x", true},
{rdMatch(0, models.RedirectRegex, `^/a/\w+$`, "/n"), "/a/a", true},
{rdMatch(0, models.RedirectRegex, `^a$`, "/n"), "", false},
{rdMatch(0, models.RedirectRegex, `(`, "/n"), "", false},
{rdMatch(0, models.RedirectRegex, `^/a\?q$`, "/n"), "", false},
} {
path, _, ok := redirectProbe(tc.rule)
if ok != tc.ok || (ok && path != tc.path) {
t.Errorf("redirectProbe(%s %q) = %q %v, want %q %v", tc.rule.Match, tc.rule.Source, path, ok, tc.path, tc.ok)
}
}
})
}