diff --git a/internal/core/routing/dispatch.go b/internal/core/routing/dispatch.go new file mode 100644 index 0000000..291fdd8 --- /dev/null +++ b/internal/core/routing/dispatch.go @@ -0,0 +1,229 @@ +package routing + +// Resolver answers the one identity question the model needs: does a GUID name +// a known identity, and is that identity enrolled? It is INJECTED — the pure +// model never touches a database (SPEC-002 §3.1, §5). +type Resolver interface { + // GUID reports whether guid names a known identity and, if so, whether + // that identity is enrolled (spec §3.5: found · not enrolled -> ENROLL; + // found · enrolled -> continue to the module). + GUID(guid string) (found, enrolled bool) +} + +// Module is one entry of the module registry: its PARAM2 name plus its own +// function whitelist and its classifier for unknown values (SPEC-002 §3.7). +// The core never parses a module's internals — it asks the module. +type Module interface { + // Name is the PARAM2 segment — the registry key ("requests", "time", …). + Name() string + + // Route resolves a path-mode function (§3.7). function is the PARAM3 + // segment ("" means absent/trailing — the module default); tail is the + // remaining segments (PARAM4+). It returns the resolved function name, + // the params that function receives, and ok=false when the value is + // neither whitelisted nor classified (-> 404). + Route(function string, tail Params) (fn string, params Params, ok bool) + + // Action resolves an action-mode function — the `action` routing field. + // ok=false -> 404. + Action(action string) (fn string, ok bool) +} + +// Registry is the module whitelist: PARAM2 in path mode and `do` in action mode +// (SPEC-002 §3.6/§3.7). SPEC-008 owns the concrete registry; the model depends +// only on this interface. +type Registry interface { + // Module returns the registered module by name. Unknown OR disabled + // modules are reported as not found (-> 404). + Module(name string) (Module, bool) +} + +// DefaultModule is the module a resolved GUID falls back to when PARAM2 is +// absent/trailing — the dashboard (SPEC-002 §3.5, §4.4 level 2: "" -> home). +const DefaultModule = "home" + +// Dispatcher is the pure model's entry point. It holds the injected identity +// resolver and module registry and resolves a request into a Resolution. +type Dispatcher struct { + Resolver Resolver + Registry Registry +} + +// New builds a Dispatcher from the injected dependencies. +func New(res Resolver, reg Registry) *Dispatcher { + return &Dispatcher{Resolver: res, Registry: reg} +} + +// Dispatch is the level-1 dispatch (SPEC-002 §3.3, D-039): keyed on the routing +// fields, never the HTTP method. +// +// if do != "" || action != "" -> Handler(do, action, path) +// else -> Router(path) +func (d *Dispatcher) Dispatch(path, do, action string) Resolution { + if do != "" || action != "" { + return d.Handler(do, action, path) + } + return d.Router(path) +} + +// Dispatch is a convenience wrapper around (*Dispatcher).Dispatch for a one-shot +// resolution with explicit dependencies. +func Dispatch(res Resolver, reg Registry, path, do, action string) Resolution { + return (&Dispatcher{Resolver: res, Registry: reg}).Dispatch(path, do, action) +} + +// Router resolves a path request (SPEC-002 §3.5): the RESERVED switch first (so +// reserved names never touch identity), then the GUID classifier, then the +// module cascade. +func (d *Dispatcher) Router(path string) Resolution { + p := Parse(path) + + // Absent PARAM1 ("" / "/") -> the ONE canonical redirect. + if len(p) == 0 { + return Resolution{Kind: KindRedirect, Redirect: "/home"} + } + + // An interior empty segment ("//") is a mismatch at any level -> 404. + if hasInteriorEmpty(p) { + return Resolution{Kind: KindNotFound, Workflow: p[0]} + } + + // Reserved cases are checked BEFORE the resolver (§3.5). + switch p[0] { + case "setup": + return Resolution{Kind: KindSetup, Workflow: "setup"} + case "home", "access", "api", "static", "media", "ws", "dev": + return reserved(p) + } + + return d.guid(p) +} + +// Handler resolves an action request (SPEC-002 §3.6). The GUID still rides the +// path (identity is never a form field); `do` selects the module and `action` +// the function — both whitelisted against the registry. +func (d *Dispatcher) Handler(do, action, path string) Resolution { + p := Parse(path) + wf := "" + if len(p) > 0 { + wf = p[0] + } + + if d.Registry == nil { + return Resolution{Kind: KindNotFound, Workflow: wf} + } + m, ok := d.Registry.Module(do) + if !ok { + return Resolution{Kind: KindNotFound, Workflow: wf} + } + fn, ok := m.Action(action) + if !ok { + return Resolution{Kind: KindNotFound, Workflow: wf} + } + + return Resolution{ + Kind: KindAction, + Workflow: wf, + Module: m.Name(), + Function: fn, + Params: tailFrom(p, 2), // the path remainder after the GUID + } +} + +// reserved classifies a reserved level-1 workflow. These are core-owned, carry +// no registry module, and hand the raw path remainder to the core workflow. +func reserved(p Params) Resolution { + return Resolution{ + Kind: KindRoute, + Workflow: p[0], + Params: tailFrom(p, 1), + } +} + +// guid classifies a non-reserved PARAM1 via the injected resolver. +func (d *Dispatcher) guid(p Params) Resolution { + guid := p[0] + + if d.Resolver == nil { + return Resolution{Kind: KindNotFound, Workflow: guid} + } + found, enrolled := d.Resolver.GUID(guid) + if !found { + return Resolution{Kind: KindNotFound, Workflow: guid} + } + if !enrolled { + return Resolution{Kind: KindEnroll, Workflow: guid} + } + + return d.module(guid, p) +} + +// module resolves PARAM2 (the module) and PARAM3+ (the module's own cascade) +// for a resolved, enrolled GUID (SPEC-002 §3.5/§3.7). +func (d *Dispatcher) module(guid string, p Params) Resolution { + // PARAM2 — the module (absent/trailing empty -> the dashboard default). + name, absent := segment(p, 1) + if absent { + name = DefaultModule + } + if d.Registry == nil { + return Resolution{Kind: KindNotFound, Workflow: guid} + } + m, ok := d.Registry.Module(name) + if !ok { + return Resolution{Kind: KindNotFound, Workflow: guid} + } + + // PARAM3 — the function (absent/trailing empty -> the module default). + fn, absent := segment(p, 2) + if absent { + fn = "" + } + resolved, params, ok := m.Route(fn, tailFrom(p, 3)) + if !ok { + return Resolution{Kind: KindNotFound, Workflow: guid} + } + + return Resolution{ + Kind: KindRoute, + Workflow: guid, + Module: m.Name(), + Function: resolved, + Params: params, + } +} + +// segment applies the presence rules for p[i] (SPEC-002 §3.4). Interior empties +// were already rejected by hasInteriorEmpty, so a "" reaching here is always a +// trailing slash — which is "absent". Callers substitute their default. +func segment(p Params, i int) (value string, absent bool) { + if i >= len(p) || p[i] == "" { + return "", true + } + return p[i], false +} + +// hasInteriorEmpty reports whether any non-final segment is empty ("//"). +func hasInteriorEmpty(p Params) bool { + for i := 0; i < len(p)-1; i++ { + if p[i] == "" { + return true + } + } + return false +} + +// tailFrom returns the remainder p[i:] for a resolved function (SPEC-002 §3.4). +// A trailing empty — a trailing slash — is "absent" and is dropped, so the +// function receives no phantom empty param. Interior empties never reach here: +// hasInteriorEmpty already rejected them. +func tailFrom(p Params, i int) Params { + if i >= len(p) { + return nil + } + rest := p[i:] + if n := len(rest); n > 0 && rest[n-1] == "" { + rest = rest[:n-1] + } + return rest +} diff --git a/internal/core/routing/fakes_test.go b/internal/core/routing/fakes_test.go new file mode 100644 index 0000000..e2a45e2 --- /dev/null +++ b/internal/core/routing/fakes_test.go @@ -0,0 +1,110 @@ +package routing + +// This file holds in-memory fakes for the two injected dependencies. It must +// import no HTTP and no DB package (SPEC-002 §5, the purity assertion). + +import ( + "fmt" + "testing" +) + +// fakeResolver is an in-memory Resolver: guid -> enrolled. A missing guid is +// "not found". It records the guids it was asked about. +type fakeResolver struct { + users map[string]bool // guid -> enrolled + calls []string +} + +func (f *fakeResolver) GUID(guid string) (bool, bool) { + f.calls = append(f.calls, guid) + enrolled, found := f.users[guid] + return found, enrolled +} + +// forbiddenResolver fails the test if it is ever called. It proves the reserved +// switch never touches identity (SPEC-002 §3.5 / test row 2). +type forbiddenResolver struct { + t *testing.T +} + +func (r forbiddenResolver) GUID(guid string) (bool, bool) { + r.t.Helper() + r.t.Errorf("resolver must not be called (guid=%q)", guid) + return false, false +} + +// fakeModule is an in-memory Module: a function whitelist, an optional numeric +// classifier, a default function, and an action whitelist. +type fakeModule struct { + name string + deflt string // function for an empty/absent PARAM3 + routes map[string]string // PARAM3 -> function + numeric bool // classify numeric PARAM3 as "detail" + actions map[string]string // action -> function +} + +func (m *fakeModule) Name() string { return m.name } + +func (m *fakeModule) Route(function string, tail Params) (string, Params, bool) { + if function == "" { + return m.deflt, tail, true + } + if fn, ok := m.routes[function]; ok { + return fn, tail, true + } + if m.numeric && IsNumeric(function) { + return "detail", Params{function}, true + } + return "", nil, false +} + +func (m *fakeModule) Action(action string) (string, bool) { + fn, ok := m.actions[action] + return fn, ok +} + +// fakeRegistry is an in-memory Registry. +type fakeRegistry map[string]Module + +func (r fakeRegistry) Module(name string) (Module, bool) { + m, ok := r[name] + return m, ok +} + +// The module set used by the test matrix (SPEC-002 §3.7 uses `requests`). +var ( + homeModule = &fakeModule{name: "home", deflt: "dashboard"} + + requestsModule = &fakeModule{ + name: "requests", + deflt: "list", + routes: map[string]string{ + "open": "open", + "closed": "closed", + "pto": "pto", + "equipment": "equipment", + }, + numeric: true, + actions: map[string]string{"submit": "submit"}, + } + + hrModule = &fakeModule{ + name: "hr", + routes: map[string]string{"save-data": "save-data"}, + actions: map[string]string{"save-data": "save-data"}, + } +) + +var testRegistry = fakeRegistry{ + "home": homeModule, + "requests": requestsModule, + "hr": hrModule, +} + +// resolveFmt renders a Resolution compactly for failure messages. +func resolveFmt(r Resolution) string { + return fmt.Sprintf( + "{Kind:%s Workflow:%q Module:%q Function:%q Params:%v Redirect:%q}", + r.Kind, r.Workflow, r.Module, r.Function, []string(r.Params), r.Redirect, + ) +} diff --git a/internal/core/routing/purity_test.go b/internal/core/routing/purity_test.go new file mode 100644 index 0000000..7a05c87 --- /dev/null +++ b/internal/core/routing/purity_test.go @@ -0,0 +1,59 @@ +package routing + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// forbiddenImportPrefixes are the packages the pure routing model must never +// pull into its build OR test graph (SPEC-002 §5, the purity assertion). +var forbiddenImportPrefixes = []string{ + "net/http", + "database/sql", +} + +// TestPurityNoHTTPOrDB makes the purity assertion real: it parses every .go +// file in this package — implementation and tests — and fails if any of them +// imports an HTTP or DB package. +func TestPurityNoHTTPOrDB(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(thisFile) + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir(%s): %v", dir, err) + } + + fset := token.NewFileSet() + checked := 0 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(dir, e.Name()), nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", e.Name(), err) + } + checked++ + for _, imp := range file.Imports { + pkg := strings.Trim(imp.Path.Value, `"`) + for _, bad := range forbiddenImportPrefixes { + if pkg == bad || strings.HasPrefix(pkg, bad+"/") { + t.Errorf("%s imports forbidden package %q (the routing model must be pure)", e.Name(), pkg) + } + } + } + } + + if checked == 0 { + t.Fatal("purity check found no .go files to inspect") + } +} diff --git a/internal/core/routing/routing.go b/internal/core/routing/routing.go new file mode 100644 index 0000000..e8b02d9 --- /dev/null +++ b/internal/core/routing/routing.go @@ -0,0 +1,159 @@ +// Package routing implements MyDay's pure routing model. +// +// It turns a request path plus the two routing fields (`do` / `action`) into a +// single Resolution for BOTH doors — ROUTER (path) and HANDLER (actions), per +// D-039 and SPEC-002. +// +// The model is deliberately PURE: +// +// - it imports no HTTP stack and no database driver; +// - it never touches storage — identity (GUID -> found/enrolled) and the +// module registry are INJECTED interfaces (§3.1); +// - so the whole cascade is exhaustively unit-testable in isolation (§5). +// +// Governing decisions: D-001 · D-004 · D-017 · D-018 · D-032 · D-039. +// Spec: docs/specs/spec-002-routing-model.md. +package routing + +import ( + "fmt" + "strings" +) + +// Params is the exploded path: the path split on "/" with EMPTY SEGMENTS +// PRESERVED (SPEC-002 §3.4). An empty segment is meaningful — a trailing empty +// is a trailing slash (→ default), while an interior empty ("//") is a mismatch +// (→ 404). +type Params []string + +// Parse strips a single leading "/", splits on "/", and PRESERVES empty +// segments (SPEC-002 §3.4): +// +// "/" -> {} +// "/home" -> {"home"} +// "/home/" -> {"home", ""} +// "/{guid}/request/pto" -> {"{guid}", "request", "pto"} +// "/a//b" -> {"a", "", "b"} +func Parse(path string) Params { + trimmed := strings.TrimPrefix(path, "/") + if trimmed == "" { + return Params{} + } + return Params(strings.Split(trimmed, "/")) +} + +// Kind is the classification of a Resolution (SPEC-002 §3.2). +type Kind int + +const ( + // KindRoute is a path-mode resolution -> (module, function, params). + KindRoute Kind = iota + // KindAction is an action-mode resolution, reached via `do`/`action`. + KindAction + // KindRedirect is the ONE canonical redirect ("/" -> "/home"). + KindRedirect + // KindNotFound is a 404. + KindNotFound + // KindEnroll is the enrollment screen (GUID found but not enrolled). + KindEnroll + // KindLogin is the login screen. The PURE model never returns this: it + // returns KindRoute and the door upgrades it when no session exists + // (SPEC-002 §6). + KindLogin + // KindSetup is the setup wizard (setup mode only). + KindSetup +) + +// String renders a Kind for logs and test failures. +func (k Kind) String() string { + switch k { + case KindRoute: + return "Route" + case KindAction: + return "Action" + case KindRedirect: + return "Redirect" + case KindNotFound: + return "NotFound" + case KindEnroll: + return "Enroll" + case KindLogin: + return "Login" + case KindSetup: + return "Setup" + default: + return fmt.Sprintf("Kind(%d)", int(k)) + } +} + +// Resolution is the single outcome of the model (SPEC-002 §3.2). One request +// resolves to one Resolution; the door then gates and dispatches it. +type Resolution struct { + // Kind classifies the outcome. + Kind Kind + // Workflow is the PARAM1 classification: a reserved workflow ("home", + // "access", "setup", "api", …) or the GUID itself; "" for the redirect. + Workflow string + // Module is the resolved registry module (PARAM2 / `do`). Empty for + // reserved workflows — those are core-owned and carry no module. + Module string + // Function is the resolved module function (PARAM3 / `action`). + Function string + // Params is the remainder handed to the function. + Params Params + // Redirect is the target path when Kind == KindRedirect. + Redirect string +} + +// ReservedWorkflows is the ONE source for the reserved level-1 names (SPEC-002 +// §4, D-004). A GUID may never equal one of these; validate on create AND +// update with ValidateWorkflowName. +// +// NOTE: kept as an exported slice because the spec declares it as a constant +// source of truth; treat it as read-only. Use IsReservedWorkflow for checks. +var ReservedWorkflows = []string{ + "home", + "access", + "setup", + "api", + "static", + "media", + "ws", + "dev", +} + +// IsReservedWorkflow reports whether name is a reserved level-1 workflow +// (exact, case-sensitive match — SPEC-002 §4). +func IsReservedWorkflow(name string) bool { + for _, w := range ReservedWorkflows { + if w == name { + return true + } + } + return false +} + +// ValidateWorkflowName rejects a name that collides with a reserved workflow. +// It is the single guard used when a GUID (or any workflow name) is created or +// updated (SPEC-002 §4, test row 22). +func ValidateWorkflowName(name string) error { + if IsReservedWorkflow(name) { + return fmt.Errorf("routing: %q is a reserved workflow name and cannot be a GUID", name) + } + return nil +} + +// IsNumeric reports whether s is a non-empty ASCII digit string. It is the +// built-in classifier modules use for unknown path values such as numeric ids +// (SPEC-002 §3.7); e.g. `/{GUID}/requests/42` -> function "detail", params {42}. +func IsNumeric(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/internal/core/routing/routing_test.go b/internal/core/routing/routing_test.go new file mode 100644 index 0000000..2de66f9 --- /dev/null +++ b/internal/core/routing/routing_test.go @@ -0,0 +1,231 @@ +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) +}