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.
This commit is contained in:
Claus Lohmar 2026-10-14 10:53:48 +00:00
parent 489321c971
commit e114a2be61
5 changed files with 788 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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