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) }