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