NextMyDay/internal/core/routing/routing_test.go
Claus Lohmar e114a2be61 feat(routing): add the pure routing model (SPEC-002)
Turns a request path plus the two routing fields (`do`/`action`) into a single
Resolution for BOTH doors (D-039): the RESERVED level-1 switch first
(home/access/setup/api/static/media/ws/dev — D-004), then the GUID classifier,
then the registry. Identity and the module registry are injected interfaces, so
the package is PURE — no HTTP stack, no DB (asserted by purity_test.go). All 22
spec rows are covered.
2026-10-14 10:53:48 +00:00

231 lines
8.5 KiB
Go

package routing
import (
"slices"
"testing"
)
// TestParse covers the SPEC-002 §3.4 table: empty segments are preserved.
func TestParse(t *testing.T) {
cases := []struct {
in string
want Params
}{
{"/", Params{}},
{"/home", Params{"home"}},
{"/home/", Params{"home", ""}},
{"/{guid}/request/pto", Params{"{guid}", "request", "pto"}},
{"/a//b", Params{"a", "", "b"}},
{"", Params{}},
}
for _, tc := range cases {
got := Parse(tc.in)
if !slices.Equal(got, tc.want) {
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestDispatchMatrix is the SPEC-002 §5 acceptance matrix — all 22 rows, no HTTP
// and no DB.
func TestDispatchMatrix(t *testing.T) {
const guid = "AbCdEf0123456789"
enrolled := &fakeResolver{users: map[string]bool{guid: true}}
notEnrolled := &fakeResolver{users: map[string]bool{guid: false}}
unknown := &fakeResolver{users: map[string]bool{}}
cases := []struct {
n int
desc string
path string
do string
action string
resolver Resolver
want Resolution
}{
// 1 — the ONE canonical redirect.
{1, "redirect", "/", "", "", forbiddenResolver{t},
Resolution{Kind: KindRedirect, Redirect: "/home"}},
// 2 — reserved home; the resolver must NOT be called.
{2, "home", "/home", "", "", forbiddenResolver{t},
Resolution{Kind: KindRoute, Workflow: "home"}},
// 3 — a trailing slash is absent -> default (no phantom param).
{3, "home/", "/home/", "", "", forbiddenResolver{t},
Resolution{Kind: KindRoute, Workflow: "home"}},
// 4 — the recovery workflow.
{4, "access", "/access", "", "", forbiddenResolver{t},
Resolution{Kind: KindRoute, Workflow: "access"}},
// 5 — the setup wizard.
{5, "setup", "/setup", "", "", forbiddenResolver{t},
Resolution{Kind: KindSetup, Workflow: "setup"}},
// 6 — unknown, not reserved, not a GUID.
{6, "unknown", "/holy-shit", "", "", unknown,
Resolution{Kind: KindNotFound, Workflow: "holy-shit"}},
// 7 — GUID found + enrolled -> the default module (dashboard).
{7, "guid enrolled", "/" + guid, "", "", enrolled,
Resolution{Kind: KindRoute, Workflow: guid, Module: DefaultModule, Function: "dashboard"}},
// 8 — GUID not found.
{8, "guid not found", "/" + guid, "", "", unknown,
Resolution{Kind: KindNotFound, Workflow: guid}},
// 9 — GUID found but not enrolled.
{9, "guid not enrolled", "/" + guid, "", "", notEnrolled,
Resolution{Kind: KindEnroll, Workflow: guid}},
// 10 — module requests / function pto.
{10, "requests pto", "/" + guid + "/requests/pto", "", "", enrolled,
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "pto"}},
// 11 — module requests / function open.
{11, "requests open", "/" + guid + "/requests/open", "", "", enrolled,
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "open"}},
// 12 — numeric -> classifier -> detail with params {id}.
{12, "requests 42", "/" + guid + "/requests/42", "", "", enrolled,
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "detail", Params: Params{"42"}}},
// 13 — module whitelist reject.
{13, "requests bogus", "/" + guid + "/requests/bogus", "", "", enrolled,
Resolution{Kind: KindNotFound, Workflow: guid}},
// 14 — unknown module.
{14, "bad module", "/" + guid + "/badmodule/x", "", "", enrolled,
Resolution{Kind: KindNotFound, Workflow: guid}},
// 15 — interior empty = mismatch.
{15, "interior empty", "/" + guid + "//pto", "", "", enrolled,
Resolution{Kind: KindNotFound, Workflow: guid}},
// 16 — module default function (the list).
{16, "requests default", "/" + guid + "/requests", "", "", enrolled,
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "list"}},
// 17 — reserved infra workflows carry their remainder.
{17, "api", "/api/v1/users", "", "", forbiddenResolver{t},
Resolution{Kind: KindRoute, Workflow: "api", Params: Params{"v1", "users"}}},
{17, "static", "/static/x.css", "", "", forbiddenResolver{t},
Resolution{Kind: KindRoute, Workflow: "static", Params: Params{"x.css"}}},
// 18 — action door: do=hr&action=save-data.
{18, "action", "/" + guid, "hr", "save-data", forbiddenResolver{t},
Resolution{Kind: KindAction, Workflow: guid, Module: "hr", Function: "save-data"}},
// 19 — unknown module -> 404.
{19, "unknown do", "/" + guid, "nope", "x", forbiddenResolver{t},
Resolution{Kind: KindNotFound, Workflow: guid}},
// 20 — unknown action on a known module -> 404.
{20, "unknown action", "/" + guid, "hr", "nope", forbiddenResolver{t},
Resolution{Kind: KindNotFound, Workflow: guid}},
// 21 — routing fields win over the path.
{21, "handler wins", "/some/path", "hr", "save-data", forbiddenResolver{t},
Resolution{Kind: KindAction, Workflow: "some", Module: "hr", Function: "save-data"}},
}
d := New(nil, testRegistry)
for _, tc := range cases {
d.Resolver = tc.resolver
got := d.Dispatch(tc.path, tc.do, tc.action)
if !eqResolution(got, tc.want) {
t.Errorf("row %d (%s): Dispatch(%q, do=%q, action=%q)\n got %s\n want %s",
tc.n, tc.desc, tc.path, tc.do, tc.action, resolveFmt(got), resolveFmt(tc.want))
}
}
}
// TestRow2ResolverNotCalled asserts the reserved switch precedes identity.
func TestRow2ResolverNotCalled(t *testing.T) {
res := &fakeResolver{users: map[string]bool{}}
d := New(res, testRegistry)
_ = d.Router("/home")
if len(res.calls) != 0 {
t.Errorf("resolver called %d time(s) for /home, want 0", len(res.calls))
}
}
// TestTrailingSlashNormalization pins the §3.4 rule: a trailing slash is
// "absent" and leaves the function with no phantom param.
func TestTrailingSlashNormalization(t *testing.T) {
const guid = "AbCdEf0123456789"
d := New(&fakeResolver{users: map[string]bool{guid: true}}, testRegistry)
cases := []struct {
path string
want Resolution
}{
{"/" + guid + "/requests/",
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "list"}},
{"/" + guid + "/requests/open/",
Resolution{Kind: KindRoute, Workflow: guid, Module: "requests", Function: "open"}},
{"/static/", Resolution{Kind: KindRoute, Workflow: "static"}},
}
for _, tc := range cases {
got := d.Router(tc.path)
if !eqResolution(got, tc.want) {
t.Errorf("Router(%q)\n got %s\n want %s", tc.path, resolveFmt(got), resolveFmt(tc.want))
}
}
}
// TestRow22ReservedGUID covers row 22: a GUID equal to a reserved workflow is
// impossible — validation rejects it on create AND update (same guard).
func TestRow22ReservedGUID(t *testing.T) {
for _, name := range ReservedWorkflows {
if !IsReservedWorkflow(name) {
t.Errorf("IsReservedWorkflow(%q) = false, want true", name)
}
if err := ValidateWorkflowName(name); err == nil {
t.Errorf("ValidateWorkflowName(%q) = nil, want an error", name)
}
}
// The reserved set is exactly the SPEC-002 §4 set.
want := []string{"home", "access", "setup", "api", "static", "media", "ws", "dev"}
if !slices.Equal(ReservedWorkflows, want) {
t.Errorf("ReservedWorkflows = %q, want %q", ReservedWorkflows, want)
}
// A normal GUID is accepted...
const guid = "AbCdEf0123456789"
if IsReservedWorkflow(guid) {
t.Errorf("IsReservedWorkflow(%q) = true, want false", guid)
}
if err := ValidateWorkflowName(guid); err != nil {
t.Errorf("ValidateWorkflowName(%q) = %v, want nil", guid, err)
}
// ...and the match is exact/case-sensitive.
if IsReservedWorkflow("Home") {
t.Error(`IsReservedWorkflow("Home") = true, want false (exact match)`)
}
}
// TestIsNumeric covers the built-in classifier used by modules.
func TestIsNumeric(t *testing.T) {
for _, s := range []string{"0", "42", "007"} {
if !IsNumeric(s) {
t.Errorf("IsNumeric(%q) = false, want true", s)
}
}
for _, s := range []string{"", "-1", "1.5", "42a", "a", " ", "١٢٣"} {
if IsNumeric(s) {
t.Errorf("IsNumeric(%q) = true, want false", s)
}
}
}
// TestKindString guards the Kind labels used in logs.
func TestKindString(t *testing.T) {
cases := map[Kind]string{
KindRoute: "Route",
KindAction: "Action",
KindRedirect: "Redirect",
KindNotFound: "NotFound",
KindEnroll: "Enroll",
KindLogin: "Login",
KindSetup: "Setup",
Kind(99): "Kind(99)",
}
for k, want := range cases {
if got := k.String(); got != want {
t.Errorf("Kind(%d).String() = %q, want %q", int(k), got, want)
}
}
}
func eqResolution(a, b Resolution) bool {
return a.Kind == b.Kind &&
a.Workflow == b.Workflow &&
a.Module == b.Module &&
a.Function == b.Function &&
a.Redirect == b.Redirect &&
slices.Equal(a.Params, b.Params)
}