- 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
465 lines
17 KiB
Go
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)
|
|
}
|
|
}
|