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:
parent
489321c971
commit
e114a2be61
5 changed files with 788 additions and 0 deletions
229
internal/core/routing/dispatch.go
Normal file
229
internal/core/routing/dispatch.go
Normal 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
|
||||
}
|
||||
110
internal/core/routing/fakes_test.go
Normal file
110
internal/core/routing/fakes_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
59
internal/core/routing/purity_test.go
Normal file
59
internal/core/routing/purity_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
159
internal/core/routing/routing.go
Normal file
159
internal/core/routing/routing.go
Normal 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
|
||||
}
|
||||
231
internal/core/routing/routing_test.go
Normal file
231
internal/core/routing/routing_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue