Files
website/server/test/modules.model.test.js
wtclaude 3add0063bf
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 1m35s
feat(modules): installed_modules and the module state machine
Phase 2 PR 1 of the module system (docs/website/MODULE_SYSTEM.md 2.7). The
table and the state machine only: no loader, no routes, no boot wiring, so
nothing an operator or a client can see changes and the route manifest diff
is zero lines.

The five states of 2.4 live in one `state` column: installed -> enabled ->
started, with disabled and startup_failed as the recoverable ones. The row is
a record of what happened, never the source of truth for what is mounted --
the loader scans the filesystem at require time, before the database is
reachable (MODULE_API.md 4.1), which is what keeps routes.manifest.json
generatable against a dead database.

Two rules the model owns and the boot path will lean on:

- Every boot resets each non-disabled row to `enabled` and clears its
  recorded failure, so a startup_failed module is retried on the next restart
  and a fixed one recovers with no admin-panel visit. `disabled` is the one
  operator decision rather than outcome, so it survives untouched -- and a
  disabled module's failure is a no-op, never a re-enable.
- A failure carries the stage it happened at, and every non-failing
  transition clears it, so a running module can never show a stale reason.

An illegal move throws instead of writing a row that misrepresents the state,
except on the two boot-path softenings noted above, because one module's
failure must never become everybody's.

22 model tests over an in-memory fake; the SQL and the DDL were round-tripped
against a real MariaDB separately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 06:35:57 -05:00

296 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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