NextMyDay/internal/core/module/registry_test.go
Claus Lohmar d7d2c7d2b6 feat(module): add the module registry, discovery and the home module (SPEC-008)
- Build-time discovery (D-028, the Caddy pattern): a module self-registers from
  init(); internal/modules/imports_gen.go (go:generate) blank-imports every
  module directory, so THE DIRECTORY IS THE SOURCE OF TRUTH
- The registry: deterministic registration order, config-backed enable/disable
  (D-038), required modules, name-prefixed migrations, Mount() into the door
- The name-collision guard now also fails a module named for a reserved
  level-1 workflow (SPEC-009 §13 Q-1) — access · static · api · media · ws ·
  dev — derived from routing.ReservedWorkflows, `home` excepted (D-2)
- home: the reference dashboard module, and the core Modules page
2026-10-14 10:53:57 +00:00

465 lines
17 KiB
Go

package module
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"path/filepath"
"slices"
"strings"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// ── fakes ────────────────────────────────────────────────────────────────────
func okHandler(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error {
w.WriteHeader(http.StatusOK)
return nil
}
// fakeModule is an in-memory Module for the registry tests.
type fakeModule struct {
name string
required bool
kind string
routes []Route
actions []Action
migs []Migration
started *int
}
func (m *fakeModule) Name() string { return m.name }
func (m *fakeModule) Required() bool { return m.required }
func (m *fakeModule) Kind() string { return m.kind }
func (m *fakeModule) Migrations() []Migration { return m.migs }
func (m *fakeModule) Routes() []Route { return m.routes }
func (m *fakeModule) Actions() []Action { return m.actions }
func (m *fakeModule) Register(r *Registry) error { return r.Add(m) }
func (m *fakeModule) Start(context.Context) error {
if m.started != nil {
*m.started++
}
return nil
}
func route(name string) Route {
return Route{Name: name, Function: name, Handler: okHandler}
}
func defaultRoute() Route {
return Route{Function: "dashboard", Handler: okHandler, Default: true}
}
// fakeResolver answers the pure model's GUID question.
type fakeResolver map[string]bool // guid -> enrolled
func (f fakeResolver) GUID(guid string) (bool, bool) {
enrolled, found := f[guid]
return found, enrolled
}
// ── AC-2: deterministic order ────────────────────────────────────────────────
func TestLoadOrderIsRegistrationOrder(t *testing.T) {
mods := []Module{
&fakeModule{name: "org", routes: []Route{defaultRoute()}},
&fakeModule{name: "time", routes: []Route{defaultRoute()}},
&fakeModule{name: "absence", routes: []Route{defaultRoute()}},
}
r, err := LoadModules(mods, nil)
if err != nil {
t.Fatalf("LoadModules: %v", err)
}
got := make([]string, 0, 3)
for _, m := range r.Modules() {
got = append(got, m.Name())
}
want := []string{"org", "time", "absence"} // registration order, never map iteration
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("Modules() = %v, want %v (AC-2)", got, want)
}
// Repeated reads are stable.
if again := r.Modules(); strings.Join(names(again), ",") != strings.Join(want, ",") {
t.Errorf("Modules() is not deterministic: %v", names(again))
}
}
// TestLogLoaded pins AC-2's "log the loaded set at startup".
func TestLogLoaded(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
r, err := LoadModules([]Module{
&fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}},
&fakeModule{name: "expense", routes: []Route{defaultRoute()}},
}, map[string]bool{"expense": false})
if err != nil {
t.Fatalf("LoadModules: %v", err)
}
r.LogLoaded(log)
out := buf.String()
for _, want := range []string{"modules loaded", "home", "expense"} {
if !strings.Contains(out, want) {
t.Errorf("startup log is missing %q:\n%s", want, out)
}
}
}
// ── AC-3: disable → not mounted (routes 404, nav hidden), data intact ────────
func TestDisabledModuleIsNotMountedAndDataSurvives(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
home := &fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}}
expense := &fakeModule{
name: "expense",
kind: "domain",
routes: []Route{route("list"), defaultRoute()},
actions: []Action{{Name: "submit", Function: "submit", Handler: okHandler}},
migs: []Migration{{Version: "0001_init",
SQL: "CREATE TABLE expense_items (item_id INTEGER PRIMARY KEY)"}},
}
// First run: expense ENABLED — its migration creates its table.
enabled, err := LoadModules([]Module{home, expense}, map[string]bool{"expense": true})
if err != nil {
t.Fatalf("LoadModules(enabled): %v", err)
}
if err := store.RunMigrations(db.DB, enabled.Migrations()); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
assertTableExists(t, db, "expense_items", true)
// Restart with expense DISABLED (D-038): it is not mounted.
disabled, err := LoadModules([]Module{home, expense}, map[string]bool{"expense": false})
if err != nil {
t.Fatalf("LoadModules(disabled): %v", err)
}
if disabled.Enabled("expense") {
t.Fatal("expense is enabled but the config disabled it (D-038)")
}
dst := server.NewMapRegistry()
disabled.Mount(dst)
res := fakeResolver{"g": true}
// The disabled module's path route 404s...
if got := routing.Dispatch(res, dst, "/g/expense/list", "", ""); got.Kind != routing.KindNotFound {
t.Errorf("/{guid}/expense/list = %+v, want NotFound (AC-3)", got)
}
// ...and its action 404s too.
if got := routing.Dispatch(res, dst, "/g", "expense", "submit"); got.Kind != routing.KindNotFound {
t.Errorf("do=expense&action=submit = %+v, want NotFound (AC-3)", got)
}
// A required module (home) still resolves.
if got := routing.Dispatch(res, dst, "/g", "", ""); got.Kind != routing.KindRoute || got.Module != "home" {
t.Errorf("/{guid} = %+v, want the home dashboard (AC-3)", got)
}
// Nav hides the disabled module.
if nav := disabled.Nav(); strings.Contains(strings.Join(nav, ","), "expense") {
t.Errorf("Nav() = %v, want no expense (AC-3: nav hides)", nav)
}
// Re-enabling restores it, and the module's table was NEVER dropped.
reenabled, err := LoadModules([]Module{home, expense}, map[string]bool{"expense": true})
if err != nil {
t.Fatalf("LoadModules(re-enabled): %v", err)
}
dst2 := server.NewMapRegistry()
reenabled.Mount(dst2)
if got := routing.Dispatch(res, dst2, "/g/expense/list", "", ""); got.Kind != routing.KindRoute {
t.Errorf("re-enabled /{guid}/expense/list = %+v, want Route (AC-3)", got)
}
assertTableExists(t, db, "expense_items", true)
}
// ── AC-4: required modules cannot be disabled (API) ──────────────────────────
func TestRequiredModuleCannotBeDisabled(t *testing.T) {
r, err := LoadModules([]Module{
&fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}},
&fakeModule{name: "expense", routes: []Route{defaultRoute()}},
}, map[string]bool{"home": false, "expense": false})
if err != nil {
t.Fatalf("LoadModules: %v", err)
}
// The config cannot disable a required module: Load keeps it on.
if !r.Enabled("home") {
t.Error("home is required but Load disabled it (AC-4)")
}
// The API refuses too.
if err := r.SetEnabled("home", false); !errors.Is(err, ErrRequiredModule) {
t.Errorf("SetEnabled(home,false) = %v, want ErrRequiredModule (AC-4)", err)
}
if err := r.SetEnabled("home", true); err != nil {
t.Errorf("SetEnabled(home,true) = %v, want nil", err)
}
// An optional module toggles fine.
if err := r.SetEnabled("expense", true); err != nil {
t.Errorf("SetEnabled(expense,true) = %v, want nil", err)
}
if err := r.SetEnabled("expense", false); err != nil {
t.Errorf("SetEnabled(expense,false) = %v, want nil", err)
}
// An unknown module is refused.
if err := r.SetEnabled("nope", true); !errors.Is(err, ErrUnknownModule) {
t.Errorf("SetEnabled(nope) = %v, want ErrUnknownModule", err)
}
}
// ── AC-5: migrations run in registration order, core owns the runner ─────────
func TestMigrationsOrderPrefixAndRunner(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
first := &fakeModule{name: "alpha", routes: []Route{defaultRoute()},
migs: []Migration{
{Version: "0001_a", SQL: "CREATE TABLE alpha_a (id INTEGER PRIMARY KEY)"},
{Version: "0002_b", SQL: "CREATE TABLE alpha_b (id INTEGER PRIMARY KEY)"},
}}
second := &fakeModule{name: "beta", routes: []Route{defaultRoute()},
migs: []Migration{{Version: "0001_init", SQL: "CREATE TABLE beta_init (id INTEGER PRIMARY KEY)"}}}
r, err := LoadModules([]Module{first, second}, nil)
if err != nil {
t.Fatalf("LoadModules: %v", err)
}
got := r.Migrations()
wantVersions := []string{"alpha/0001_a", "alpha/0002_b", "beta/0001_init"}
if len(got) != len(wantVersions) {
t.Fatalf("Migrations() = %d, want %d (AC-5)", len(got), len(wantVersions))
}
for i, v := range wantVersions {
if got[i].Version != v {
t.Errorf("Migrations()[%d].Version = %q, want %q (order + prefix, AC-5)", i, got[i].Version, v)
}
}
// The CORE runs them (store.RunMigrations), and both tables appear.
if err := store.RunMigrations(db.DB, got); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
for _, table := range []string{"alpha_a", "alpha_b", "beta_init"} {
assertTableExists(t, db, table, true)
}
// A disabled module contributes no migrations.
r2, err := LoadModules([]Module{first, second}, map[string]bool{"beta": false})
if err != nil {
t.Fatalf("LoadModules: %v", err)
}
for _, m := range r2.Migrations() {
if strings.HasPrefix(m.Version, "beta/") {
t.Errorf("a disabled module contributed migration %q (AC-5)", m.Version)
}
}
}
// ── validation & failures ────────────────────────────────────────────────────
func TestLoadRejectsDuplicateAndMalformed(t *testing.T) {
dup := []Module{
&fakeModule{name: "x", routes: []Route{defaultRoute()}},
&fakeModule{name: "x", routes: []Route{defaultRoute()}},
}
if _, err := LoadModules(dup, nil); err == nil {
t.Error("duplicate module name was accepted")
}
cases := map[string]Module{
"empty name": &fakeModule{name: "", routes: []Route{defaultRoute()}},
"two defaults": &fakeModule{name: "x", routes: []Route{defaultRoute(), {Function: "y", Handler: okHandler, Default: true}}},
"nil handler": &fakeModule{name: "x", routes: []Route{{Name: "r", Function: "r"}}},
"empty function": &fakeModule{name: "x", routes: []Route{{Name: "r", Handler: okHandler}}},
"duplicate route": &fakeModule{name: "x", routes: []Route{route("r"), route("r")}},
"empty action name": &fakeModule{name: "x", routes: []Route{defaultRoute()}, actions: []Action{{Function: "f", Handler: okHandler}}},
}
for label, m := range cases {
if _, err := LoadModules([]Module{m}, nil); err == nil {
t.Errorf("%s was accepted, want an error (fail closed)", label)
}
}
}
// ── AC-6 (SPEC-009 §8/§13 · SPEC-008 §9 D-9 · SPEC-009 §13 Q-1): the guard ───
// TestAC6_DiscoveredModuleCollidingWithACoreDispatchNameFailsToLoad: a module
// claiming a name the CORE dispatches for itself — a core dispatch module
// (`auth` · `setup` · `recovery` · `core`) OR a reserved level-1 workflow
// (`access` · `setup` · `api` · `static` · `media` · `ws` · `dev`, SPEC-009 §13
// Q-1) — must FAIL TO LOAD rather than silently losing precedence or shadowing
// the core (the door's RegisterModule overwrites by name).
func TestAC6_DiscoveredModuleCollidingWithACoreDispatchNameFailsToLoad(t *testing.T) {
if len(CoreDispatchNames) == 0 {
t.Fatal("CoreDispatchNames is empty — the guard would never fire")
}
if len(ReservedWorkflowNames) == 0 {
t.Fatal("ReservedWorkflowNames is empty — Q-1's extension is missing")
}
// Every guarded name — core dispatch module or reserved level-1 workflow —
// must fail to load.
for _, name := range ReservedModuleNames {
t.Run(name, func(t *testing.T) {
colliding := &fakeModule{name: name, routes: []Route{defaultRoute()}}
_, err := LoadModules([]Module{colliding}, nil)
if err == nil {
t.Fatalf("a discovered module named %q loaded, want a hard failure (AC-6)", name)
}
if !errors.Is(err, ErrCoreDispatchName) {
t.Errorf("LoadModules(%q) = %v, want ErrCoreDispatchName (AC-6)", name, err)
}
// The registry is not half-built: the guard runs before Add.
r := NewRegistry()
if err := r.Add(colliding); !errors.Is(err, ErrCoreDispatchName) {
t.Errorf("Add(%q) = %v, want ErrCoreDispatchName", name, err)
}
if r.Enabled(name) {
t.Errorf("%q reached the registry despite the guard (AC-6)", name)
}
})
}
// The predicates are the single source of truth the guard consults.
for _, name := range CoreDispatchNames {
if !IsCoreDispatchName(name) || !IsReservedModuleName(name) {
t.Errorf("IsCoreDispatchName/IsReservedModuleName(%q) = false, want true", name)
}
}
for _, name := range ReservedWorkflowNames {
if !IsReservedWorkflowName(name) || !IsReservedModuleName(name) {
t.Errorf("IsReservedWorkflowName/IsReservedModuleName(%q) = false, want true", name)
}
}
// `home` is the documented EXCEPTION (SPEC-002 §4.4, SPEC-009 §13 D-2: the
// reserved level-1 workflow and the PARAM2 dashboard are separate switches —
// the dashboard IS a discovered module, the reference one). It must load.
if IsCoreDispatchName("home") || IsReservedWorkflowName("home") || IsReservedModuleName("home") {
t.Error("`home` is guarded, but the reference dashboard module is named `home`")
}
if _, err := LoadModules([]Module{&fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}}}, nil); err != nil {
t.Errorf("the `home` dashboard module failed to load: %v", err)
}
// An ordinary, non-colliding module is unaffected.
if _, err := LoadModules([]Module{&fakeModule{name: "expense", routes: []Route{defaultRoute()}}}, nil); err != nil {
t.Errorf("a non-colliding module failed to load: %v", err)
}
}
// TestReservedWorkflowNamesDeriveFromRouting pins the Q-1 extension: the guarded
// reserved set is DERIVED from routing.ReservedWorkflows — the one source for
// the reserved level-1 names (D-004) — with `home` removed (SPEC-009 §13 D-2),
// so it can never drift out of sync.
func TestReservedWorkflowNamesDeriveFromRouting(t *testing.T) {
// The derivation: routing.ReservedWorkflows minus `home`, order preserved.
derived := make([]string, 0, len(routing.ReservedWorkflows))
for _, n := range routing.ReservedWorkflows {
if n != "home" {
derived = append(derived, n)
}
}
if !slices.Equal(ReservedWorkflowNames, derived) {
t.Errorf("ReservedWorkflowNames = %v, want the routing source minus home %v",
ReservedWorkflowNames, derived)
}
// And the concrete expectation the ruling names (Q-1).
want := []string{"access", "setup", "api", "static", "media", "ws", "dev"}
if !slices.Equal(ReservedWorkflowNames, want) {
t.Errorf("ReservedWorkflowNames = %v, want %v", ReservedWorkflowNames, want)
}
// The full guarded set is the union of both categories, de-duplicated.
union := append(append([]string{}, CoreDispatchNames...), ReservedWorkflowNames...)
if !slices.Equal(ReservedModuleNames, unionNames(union)) {
t.Errorf("ReservedModuleNames = %v, want the union %v", ReservedModuleNames, unionNames(union))
}
// Every name in the union is guarded; nothing else is.
for _, n := range union {
if !IsReservedModuleName(n) {
t.Errorf("IsReservedModuleName(%q) = false, want true", n)
}
}
for _, n := range []string{"home", "expense", "org", "time", "absence"} {
if IsReservedModuleName(n) {
t.Errorf("IsReservedModuleName(%q) = true, want false", n)
}
}
}
func TestStartRunsEnabledOnly(t *testing.T) {
var enabled, disabled int
r, err := LoadModules([]Module{
&fakeModule{name: "on", routes: []Route{defaultRoute()}, started: &enabled},
&fakeModule{name: "off", routes: []Route{defaultRoute()}, started: &disabled},
}, map[string]bool{"off": false})
if err != nil {
t.Fatalf("LoadModules: %v", err)
}
if err := r.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
if enabled != 1 {
t.Errorf("enabled module Start calls = %d, want 1", enabled)
}
if disabled != 0 {
t.Errorf("disabled module Start calls = %d, want 0", disabled)
}
}
func TestKindOf(t *testing.T) {
if got := KindOf(&fakeModule{name: "x", kind: "domain"}); got != "domain" {
t.Errorf("KindOf = %q, want domain", got)
}
if got := KindOf(&fakeModule{name: "x"}); got != "module" {
t.Errorf("KindOf default = %q, want module", got)
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
func names(mods []Module) []string {
out := make([]string, 0, len(mods))
for _, m := range mods {
out = append(out, m.Name())
}
return out
}
func assertTableExists(t *testing.T, db *store.DB, table string, want bool) {
t.Helper()
var n int
err := db.QueryRow(
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&n)
if err != nil {
t.Fatalf("sqlite_master: %v", err)
}
if (n > 0) != want {
t.Errorf("table %q exists = %v, want %v", table, n > 0, want)
}
}