// Point the DB pool at a dead port before it's built; every modules.db method is // monkeypatched below, and pool.close() at the end lets the process exit cleanly. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const pool = require('../src/utils/db') after(() => pool.close()) // Unit-test the module state machine (docs/website/MODULE_SYSTEM.md §2.4) against an // in-memory fake by monkeypatching modules.db (no DB). What is locked here is // everything the boot path and the admin panel will lean on in later phases: // - `installed` is transient and a re-install never overwrites the operator's // enable/disable decision; // - beginBoot() recomputes outcomes and leaves `disabled` alone — the property that // makes a fixed module recover on a restart with no panel visit; // - a failure carries its stage and reason, and every non-failing transition clears // them, so a running module can never display a stale reason; // - a disabled module's failure is a no-op, because the boot path must never turn // one module's failure into a re-enable of a module the operator switched off; // - an illegal move throws instead of writing a row that misrepresents the state. const modulesDb = require('../src/model/modules/modules.db') const modules = require('../src/model/modules/modules.model') let rows // id → row, snake_case exactly as modules.db returns it const saved = { ...modulesDb } function reset() { rows = new Map() } modulesDb.listAll = async () => [...rows.values()].sort((a, b) => a.id.localeCompare(b.id)) modulesDb.getOne = async (id) => (rows.has(id) ? [rows.get(id)] : []) modulesDb.upsert = async ({ id, name, version, source, sha256 }) => { const existing = rows.get(id) if (existing) { Object.assign(existing, { name, version, source: source ?? null, sha256: sha256 ?? null }) return { affectedRows: 1 } } rows.set(id, { id, name, version, state: 'installed', failure_stage: null, failure_reason: null, source: source ?? null, sha256: sha256 ?? null, installed_at: '2026-08-10T00:00:00Z', started_at: null, updated_at: '2026-08-10T00:00:00Z', }) return { affectedRows: 1 } } modulesDb.setState = async ({ id, state, failureStage, failureReason, stampStarted }) => { const row = rows.get(id) if (!row) return { affectedRows: 0 } row.state = state row.failure_stage = failureStage ?? null row.failure_reason = failureReason ?? null if (stampStarted) row.started_at = '2026-08-10T12:00:00Z' return { affectedRows: 1 } } modulesDb.resetForBoot = async () => { let n = 0 for (const row of rows.values()) { if (row.state === 'disabled') continue row.state = 'enabled' row.failure_stage = null row.failure_reason = null n += 1 } return { affectedRows: n } } modulesDb.remove = async (id) => ({ affectedRows: rows.delete(id) ? 1 : 0 }) after(() => Object.assign(modulesDb, saved)) beforeEach(reset) // Install one module and put it in a given state, bypassing the machine so a test // can start from any state without asserting its way there. async function seed(id, state = 'installed', extra = {}) { await modules.recordInstalled({ id, name: `Module ${id}`, version: '1.0.0', ...extra }) rows.get(id).state = state return modules.get(id) } // ── recordInstalled ─────────────────────────────────────────────────── test('a new install lands in the transient installed state', async () => { const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.2.0', source: 'https://gitea.example/releases/module-uo-1.2.0.tar.gz', sha256: 'a'.repeat(64), }) assert.equal(mod.state, 'installed') assert.equal(mod.version, '1.2.0') assert.equal(mod.sha256, 'a'.repeat(64)) assert.equal(mod.startedAt, null) assert.equal(mod.failureReason, null) }) test('a hand-placed module records with no provenance', async () => { const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.0.0' }) assert.equal(mod.source, null) assert.equal(mod.sha256, null) }) test('recordInstalled refuses a manifest missing id, name or version', async () => { await assert.rejects( () => modules.recordInstalled({ id: 'uo', version: '1.0.0' }), (err) => err.code === 'invalid_module', ) }) test('an upgrade refreshes metadata and leaves the operator decision alone', async () => { await seed('uo', 'disabled') const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '2.0.0' }) assert.equal(mod.version, '2.0.0') assert.equal(mod.name, 'Ultima Online') assert.equal(mod.state, 'disabled', 're-installing must not silently re-enable') }) test('an upgrade of a started module does not switch it off', async () => { await seed('uo', 'started') const mod = await modules.recordInstalled({ id: 'uo', name: 'Module uo', version: '1.1.0' }) assert.equal(mod.state, 'started') }) // ── the machine ─────────────────────────────────────────────────────── test('installed → enabled → started, stamping the start', async () => { await seed('uo') assert.equal((await modules.enable('uo')).state, 'enabled') const started = await modules.markStarted('uo') assert.equal(started.state, 'started') assert.ok(started.startedAt, 'a successful start is stamped') }) test('a first boot may start a module straight from installed', async () => { await seed('uo') assert.equal((await modules.markStarted('uo')).state, 'started') }) test('a disabled module can be re-enabled', async () => { await seed('uo', 'disabled') assert.equal((await modules.enable('uo')).state, 'enabled') }) test('a failed module is retried by enabling it, which clears the reason', async () => { await seed('uo', 'enabled') await modules.markStartupFailed('uo', { stage: 'boot', reason: 'atlas refresh threw' }) const retried = await modules.enable('uo') assert.equal(retried.state, 'enabled') assert.equal(retried.failureStage, null) assert.equal(retried.failureReason, null) }) test('a running module can be disabled', async () => { await seed('uo', 'started') assert.equal((await modules.disable('uo')).state, 'disabled') }) test('a disabled module is never started — that would be a core bug, so it throws', async () => { await seed('uo', 'disabled') await assert.rejects( () => modules.markStarted('uo'), (err) => err.name === 'ModuleStateError' && err.code === 'illegal_transition', ) assert.equal((await modules.get('uo')).state, 'disabled') }) test('a transition on a module with no row writes nothing and returns null', async () => { assert.equal(await modules.enable('ghost'), null) assert.equal(await modules.markStarted('ghost'), null) assert.equal(rows.size, 0) }) // ── failures ────────────────────────────────────────────────────────── test('a failure records its stage and reason', async () => { await seed('uo', 'enabled') const failed = await modules.markStartupFailed('uo', { stage: 'core_api', reason: "module 'uo' needs coreApi ^2.0.0, core provides 1.0.0", }) assert.equal(failed.state, 'startup_failed') assert.equal(failed.failureStage, 'core_api') assert.match(failed.failureReason, /coreApi/) }) test('an unrecognised stage is still recorded, as require', async () => { await seed('uo', 'enabled') const failed = await modules.markStartupFailed('uo', { stage: 'nonsense', reason: 'boom' }) assert.equal(failed.failureStage, 'require') assert.equal(failed.failureReason, 'boom') }) test('a missing reason still produces a displayable one', async () => { await seed('uo', 'enabled') const failed = await modules.markStartupFailed('uo', { stage: 'register' }) assert.equal(failed.failureReason, 'unknown error') }) test('a runaway reason is truncated rather than refused', async () => { await seed('uo', 'enabled') const failed = await modules.markStartupFailed('uo', { stage: 'boot', reason: 'x'.repeat(9000) }) assert.equal(failed.failureReason.length, 4000) }) test("a disabled module's failure is a no-op, not a re-enable", async () => { await seed('uo', 'disabled') const unchanged = await modules.markStartupFailed('uo', { stage: 'require', reason: 'broken' }) assert.equal(unchanged.state, 'disabled') assert.equal(unchanged.failureReason, null) }) test('starting successfully clears the previous failure', async () => { await seed('uo', 'enabled') await modules.markStartupFailed('uo', { stage: 'schema', reason: 'bad fragment' }) await modules.enable('uo') const started = await modules.markStarted('uo') assert.equal(started.failureStage, null) assert.equal(started.failureReason, null) }) // ── boot ────────────────────────────────────────────────────────────── test('beginBoot recomputes outcomes and leaves disabled alone', async () => { await seed('a', 'started') await seed('b', 'startup_failed') await seed('c', 'disabled') await seed('d', 'installed') rows.get('b').failure_reason = 'last boot blew up' rows.get('b').failure_stage = 'boot' assert.equal(await modules.beginBoot(), 3) const byId = Object.fromEntries((await modules.list()).map((m) => [m.id, m])) assert.equal(byId.a.state, 'enabled') assert.equal(byId.b.state, 'enabled', 'a failed module is retried on the next boot') assert.equal(byId.b.failureReason, null, 'and last boot’s reason is cleared') assert.equal(byId.c.state, 'disabled', 'the operator decision survives a boot') assert.equal(byId.d.state, 'enabled') }) test('beginBoot keeps the start stamp of a module that was running', async () => { await seed('uo', 'enabled') await modules.markStarted('uo') await modules.beginBoot() assert.ok((await modules.get('uo')).startedAt, 'started_at is the last successful start') }) // ── list / purge ────────────────────────────────────────────────────── test('list returns every module, id-ordered and serialized', async () => { await seed('zzz') await seed('aaa') const all = await modules.list() assert.deepEqual( all.map((m) => m.id), ['aaa', 'zzz'], ) assert.deepEqual(Object.keys(all[0]).sort(), [ 'failureReason', 'failureStage', 'id', 'installedAt', 'name', 'sha256', 'source', 'startedAt', 'state', 'updatedAt', 'version', ]) }) test('purge drops the row; uninstall is a disable and keeps it', async () => { await seed('uo', 'started') await modules.disable('uo') assert.ok(await modules.get('uo'), 'uninstall keeps the row and its data') await modules.remove('uo') assert.equal(await modules.get('uo'), null) })