// ── The events admin surface (EVENTS.md § API surface, Phase 1) ──────────── // // `eventSpec.test.js` covers the spec validator and `eventActionRegistry.test.js` // the registry; re-asserting either here would be a second copy of a test rather // than a second test. What is genuinely new is what the SURFACE decides: // // • **publish snapshots.** It cuts an immutable version, points the definition // at it, and a later edit does not touch the version a run would pin. // • **publish re-validates against the registries as they stand now**, not // against the save that wrote the spec. A module uninstalled in between must // block the publish, because the alternative is a run that fails at dispatch // with the world half-changed. // • **an empty event does not publish.** It would run cleanly and do nothing, // which reads as a broken run rather than an empty one. // • **the slug is frozen after create**, because the public event page lives // at it and a retitle must not break a posted link. // • **archiving is refused while a run is in flight**, and there is no hard // delete at all. // • **creating an occurrence twice creates ONE run.** The unique index is what // makes that true, so the second call answers `created: false` with the // existing row rather than erroring. // • **a created run stays `scheduled`.** There is no runner until Phase 2, and // that has to be visible as the correct state rather than as a stall. // // The `.db` layer is stubbed in-memory and the real models and controllers run // against it, the shape `engagementAdmin.test.js` uses. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, afterEach, after } = require('node:test') const assert = require('node:assert/strict') const registries = require('../src/modules/registries') const ctrl = require('../src/router/v1/admin/events.controller') const definitionsDb = require('../src/model/events/eventDefinitions.db') const versionsDb = require('../src/model/events/eventVersions.db') const runsDb = require('../src/model/events/eventRuns.db') const stepsDb = require('../src/model/events/eventRunSteps.db') const logDb = require('../src/model/events/eventRunLog.db') const seriesDb = require('../src/model/events/eventSeries.db') const activity = require('../src/model/activity/activity.model') const db = require('../src/utils/db') after(() => db.close()) // ── In-memory stand-ins ──────────────────────────────────────────────────── let store const originals = {} for (const [name, mod] of [ ['definitionsDb', definitionsDb], ['versionsDb', versionsDb], ['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['seriesDb', seriesDb], ['activity', activity], ]) { originals[name] = { mod, fns: { ...mod } } } const restoreOriginals = () => { for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) } // The occurrence key, as a string, so the stub can enforce the UNIQUE index the // real table enforces. Reproducing it is the point of several tests below: the // index — not the claim — is what makes "one run per occurrence per scope" true. const occurrenceKey = (definitionId, scope, when) => `${definitionId}|${scope || ''}|${new Date(when).toISOString()}` function installStubs() { store = { definitions: new Map(), versions: new Map(), runs: new Map(), steps: new Map(), log: [], series: new Map(), occurrences: new Set(), nextDefinition: 1, nextVersion: 1, nextRun: 1, nextStep: 1, } const shape = (d) => ({ ...d, series_name: store.series.get(d.series_id)?.name ?? null, series_slug: store.series.get(d.series_id)?.slug ?? null, current_version: store.versions.get(d.current_version_id)?.version ?? null, }) definitionsDb.list = async ({ state = null } = {}) => [...store.definitions.values()].filter((d) => !state || d.state === state).map(shape) definitionsDb.getById = async (id) => store.definitions.has(id) ? shape(store.definitions.get(id)) : undefined definitionsDb.getBySlug = async (slug) => [...store.definitions.values()].filter((d) => d.slug === slug).map(shape)[0] definitionsDb.slugTaken = async (slug, exceptId = null) => [...store.definitions.values()].some((d) => d.slug === slug && d.id !== exceptId) definitionsDb.insert = async (d) => { const id = store.nextDefinition++ store.definitions.set(id, { id, state: 'draft', current_version_id: null, created_at: new Date(), updated_at: new Date(), ...d, }) return id } definitionsDb.update = async (id, d) => { const existing = store.definitions.get(id) if (existing) Object.assign(existing, d, { updated_at: new Date() }) } definitionsDb.markReady = async (id, versionId, userId) => { const existing = store.definitions.get(id) if (existing) { Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId }) } } definitionsDb.archive = async (id, userId) => { const existing = store.definitions.get(id) if (existing) Object.assign(existing, { state: 'archived', updated_by: userId }) } versionsDb.listForDefinition = async (definitionId) => [...store.versions.values()] .filter((v) => v.definition_id === definitionId) .sort((a, b) => b.version - a.version) versionsDb.getById = async (id) => store.versions.get(id) || undefined versionsDb.nextVersion = async (definitionId) => [...store.versions.values()].filter((v) => v.definition_id === definitionId).length + 1 versionsDb.insert = async (definitionId, version, spec, userId) => { const id = store.nextVersion++ // Deep-copied on the way in, because the whole point of a version is that a // later edit of the working spec cannot reach it. A stub that stored the // reference would make the snapshot test pass for the wrong reason. store.versions.set(id, { id, definition_id: definitionId, version, spec: JSON.parse(JSON.stringify(spec)), published_at: new Date(), published_by: userId, }) return id } const shapeRun = (r) => ({ ...r, definition_title: store.definitions.get(r.definition_id)?.title ?? null, definition_slug: store.definitions.get(r.definition_id)?.slug ?? null, version_number: store.versions.get(r.version_id)?.version ?? null, }) runsDb.list = async ({ definitionId = null, status = null } = {}) => [...store.runs.values()] .filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status)) .map(shapeRun) runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined) runsDb.listScheduledFor = async (definitionId) => [...store.runs.values()] .filter((r) => r.definition_id === definitionId && r.status === 'scheduled' && !r.started_at) .map((r) => ({ id: r.id, version_id: r.version_id, scheduled_for: r.scheduled_for })) runsDb.repinScheduled = async (definitionId, versionId) => { let moved = 0 for (const run of store.runs.values()) { if (run.definition_id !== definitionId) continue if (run.status !== 'scheduled' || run.started_at) continue if (run.version_id === versionId) continue run.version_id = versionId moved += 1 } return moved } runsDb.materialise = async (run) => { const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for) if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job store.occurrences.add(key) const id = store.nextRun++ store.runs.set(id, { id, status: 'scheduled', health: 'ok', cleanup_status: 'not_required', current_phase: null, started_at: null, ended_at: null, last_error: null, created_at: new Date(), ...run, scheduled_for: new Date(run.scheduled_for), rehearsal: Boolean(run.rehearsal), }) return id } runsDb.findOccurrence = async (definitionId, scope, when) => [...store.runs.values()] .filter( (r) => occurrenceKey(r.definition_id, r.scope, r.scheduled_for) === occurrenceKey(definitionId, scope, when), ) .map(shapeRun)[0] runsDb.countActiveForDefinition = async (definitionId) => [...store.runs.values()].filter( (r) => r.definition_id === definitionId && ['scheduled', 'starting', 'running', 'paused', 'ending'].includes(r.status), ).length stepsDb.listForRun = async (runId) => [...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq) stepsDb.getById = async (id) => store.steps.get(id) || undefined stepsDb.materialisePhase = async (runId, phase, steps) => { steps.forEach((step, seq) => { const taken = [...store.steps.values()].some( (s) => s.run_id === runId && s.phase === phase && s.seq === seq, ) if (taken) return const id = store.nextStep++ store.steps.set(id, { id, run_id: runId, phase, seq, action_id: step.actionId, params: step.params || {}, action_version: step.actionVersion || 1, status: 'pending', due_at: null, attempts: 0, on_failure: step.onFailure || 'pause', // The real materialiser stamps this from the row's own id, so the stub // does too: a key that varied by attempt would defeat the whole retry // story, and a stub that faked it would hide that. idempotency_key: stepsDb.idempotencyKey(runId, id), last_error: null, started_at: null, finished_at: null, }) }) return stepsDb.listForRun(runId) } stepsDb.statusCounts = async (runId) => { const counts = {} for (const s of store.steps.values()) { if (s.run_id === runId) counts[s.status] = (counts[s.status] || 0) + 1 } return counts } logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse() logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => { store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() }) return true } seriesDb.list = async () => [...store.series.values()] seriesDb.getById = async (id) => store.series.get(id) || null seriesDb.exists = async (id) => store.series.has(id) // The audit log is a side effect, not a subject: it writes to a real table and // never throws into the request path, so the stub records and stays quiet. activity.log = async (entry) => { store.log.push({ audit: true, ...entry }) return true } } // ── Fixtures ─────────────────────────────────────────────────────────────── beforeEach(() => { registries._reset() registries.registerCore() installStubs() }) afterEach(() => { registries._reset() restoreOriginals() }) /** A module whose one action a definition can be built around. */ function registerDemoModule() { const api = registries.stage('demo') api.registerEventActions([ { id: 'demo.world.change', label: 'Change the world', risk: 'change', reversible: 'none', params: [{ name: 'region', type: 'string', required: true, example: 'Yew' }], perform: async () => ({ ok: true }), }, ]) registries.apply(api.staged) } const announceStep = (body = 'The gates open at dusk.') => ({ actionId: 'core.announce', params: { leg: 'discord', body }, }) const draftBody = (over = {}) => ({ title: 'The Siege of Cove', summary: 'An invasion, in three phases.', timezone: 'Europe/Berlin', spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }], }, ...over, }) function mockRes() { return { statusCode: 200, body: null, status(c) { this.statusCode = c; return this }, json(b) { this.body = b; return this }, } } async function call(handler, req) { const res = mockRes() let thrown = null await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => { thrown = err }) if (thrown) throw thrown return res } const createDraft = async (over = {}) => call(ctrl.create, { body: draftBody(over) }) // ── The catalog ──────────────────────────────────────────────────────────── test('the catalog serves the registry, callables stripped, with its vocabularies', async () => { const res = await call(ctrl.catalog, {}) assert.equal(res.statusCode, 200) assert.deepEqual( res.body.actions.map((a) => a.id), ['core.announce', 'core.wait', 'core.cue'], ) for (const action of res.body.actions) assert.equal(action.perform, undefined) assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible']) assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run']) // Phase 1 is honest about what it does not have: budget dimensions arrive with // the module contract, so the catalog does not pretend to carry any. assert.equal(res.body.budgets, undefined) }) // ── Create, edit, slug ───────────────────────────────────────────────────── test('a draft is created with a derived slug and no version', async () => { const res = await createDraft() assert.equal(res.statusCode, 201) assert.equal(res.body.event.state, 'draft') assert.equal(res.body.event.slug, 'the-siege-of-cove') assert.equal(res.body.event.currentVersionId, null) assert.equal(res.body.event.timezone, 'Europe/Berlin') }) test('the slug is frozen after create — a retitle does not move the public page', async () => { const created = await createDraft() const id = created.body.event.id const res = await call(ctrl.update, { params: { id: String(id) }, body: draftBody({ title: 'The Second Siege of Cove' }), }) assert.equal(res.statusCode, 200) assert.equal(res.body.event.title, 'The Second Siege of Cove') assert.equal(res.body.event.slug, 'the-siege-of-cove') }) test('a bad timezone and a bad grace window are refused with both problems named', async () => { const res = await createDraft({ timezone: 'Middle/Earth', graceSeconds: 5 }) assert.equal(res.statusCode, 400) const joined = res.body.errors.join('\n') assert.match(joined, /not an IANA zone name/) assert.match(joined, /graceSeconds must be an integer/) }) test('a step naming an unregistered action is refused at create', async () => { const res = await createDraft({ spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'ghost.verb.do' }] }], }, }) assert.equal(res.statusCode, 400) assert.match(res.body.errors.join('\n'), /no module registers "ghost\.verb\.do"/) }) // ── Publish ──────────────────────────────────────────────────────────────── test('publish snapshots the spec, and a later edit does not touch the version', async () => { const created = await createDraft() const id = created.body.event.id const published = await call(ctrl.publish, { params: { id: String(id) } }) assert.equal(published.statusCode, 200) assert.equal(published.body.version, 1) assert.equal(published.body.event.state, 'ready') // Edit the working copy afterwards. await call(ctrl.update, { params: { id: String(id) }, body: draftBody({ spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [announceStep('Something else entirely.')] }], }, }), }) const version = await versionsDb.getById(published.body.versionId) assert.equal(version.spec.phases[0].steps[0].params.body, 'The gates open at dusk.') // …and publishing again cuts version 2 rather than mutating version 1. const again = await call(ctrl.publish, { params: { id: String(id) } }) assert.equal(again.body.version, 2) const versions = await call(ctrl.listVersions, { params: { id: String(id) } }) assert.deepEqual(versions.body.versions.map((v) => v.version), [2, 1]) assert.deepEqual(versions.body.versions.map((v) => v.current), [true, false]) }) test('publish is refused when a step went dormant after the save that wrote it', async () => { registerDemoModule() const created = await createDraft({ spec: { schedule: { kind: 'manual' }, phases: [ { key: 'main', label: 'Main', steps: [{ actionId: 'demo.world.change', params: { region: 'Yew' } }] }, ], }, }) const id = created.body.event.id // The module is uninstalled between the save and the publish. registries._reset() registries.registerCore() const res = await call(ctrl.publish, { params: { id: String(id) } }) assert.equal(res.statusCode, 409) assert.match(res.body.errors.join('\n'), /no module registers demo\.world\.change/) // …and the definition is still editable, which is the other half of the rule: // an uninstall must not be destructive after the fact. const saved = await call(ctrl.update, { params: { id: String(id) }, body: { title: 'Renamed' } }) assert.equal(saved.statusCode, 200) assert.equal(saved.body.event.spec.phases[0].steps[0].dormant, true) }) test('an event with no steps does not publish', async () => { const created = await createDraft({ spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] }, }) const res = await call(ctrl.publish, { params: { id: String(created.body.event.id) } }) assert.equal(res.statusCode, 400) assert.match(res.body.errors.join('\n'), /no phase has any steps/) }) // ── Runs ─────────────────────────────────────────────────────────────────── test('a draft has nothing to run', async () => { const created = await createDraft() const res = await call(ctrl.startRun, { params: { id: String(created.body.event.id) } }) assert.equal(res.statusCode, 409) assert.match(res.body.errors.join('\n'), /no published version to run/) }) test('a created run stays scheduled, with its first phase materialised', async () => { const created = await createDraft() const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const started = await call(ctrl.startRun, { params: { id: String(id) } }) assert.equal(started.statusCode, 201) assert.equal(started.body.created, true) // The correct state for this phase, and it has to be visible as such rather // than looking like a stall: there is no runner until Phase 2. assert.equal(started.body.run.status, 'scheduled') const detail = await call(ctrl.getRun, { params: { runId: String(started.body.run.id) } }) assert.equal(detail.body.steps.length, 1) assert.equal(detail.body.steps[0].actionId, 'core.announce') assert.equal(detail.body.steps[0].status, 'pending') assert.deepEqual(detail.body.counts, { pending: 1 }) // Minted at materialisation, 40 hex, and a function of identity alone. assert.match(detail.body.steps[0].idempotencyKey, /^[0-9a-f]{40}$/) }) test('one occurrence, asked for twice, is one run', async () => { const created = await createDraft() const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const when = '2026-10-31T20:00:00.000Z' const first = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } }) const second = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } }) assert.equal(first.statusCode, 201) assert.equal(first.body.created, true) // Not an error — the unique index doing exactly what it is for. The existing // row is the answer. assert.equal(second.statusCode, 200) assert.equal(second.body.created, false) assert.equal(second.body.run.id, first.body.run.id) const runs = await call(ctrl.listRuns, { query: { definitionId: String(id) } }) assert.equal(runs.body.runs.length, 1) }) test('the same instant in two scopes is two runs', async () => { const created = await createDraft() const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const when = '2026-10-31T20:00:00.000Z' const a = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'europa' } }) const b = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'atlantic' } }) assert.equal(a.body.created, true) assert.equal(b.body.created, true) assert.notEqual(a.body.run.id, b.body.run.id) }) test('the concurrency key is rendered from the run params', async () => { const created = await createDraft({ concurrencyKey: 'invasion:{region}' }) const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const res = await call(ctrl.startRun, { params: { id: String(id) }, body: { params: { region: 'Yew' } }, }) assert.equal(res.body.run.concurrencyKey, 'invasion:Yew') }) test('an unrendered placeholder is left standing rather than emptied', async () => { // `invasion:` would collide with every other unrendered key on the deployment, // which is the opposite of what a concurrency key is for. const created = await createDraft({ concurrencyKey: 'invasion:{region}' }) const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const res = await call(ctrl.startRun, { params: { id: String(id) } }) assert.equal(res.body.run.concurrencyKey, 'invasion:{region}') }) test('the run log records the creation and the phase entry', async () => { const created = await createDraft() const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const started = await call(ctrl.startRun, { params: { id: String(id) } }) const res = await call(ctrl.getRunLog, { params: { runId: String(started.body.run.id) } }) const kinds = res.body.log.map((l) => l.kind) assert.ok(kinds.includes('run.created')) assert.ok(kinds.includes('phase.entered')) }) test('an unknown log kind is refused rather than stored', async () => { // The closed set is enforced in the db layer, not by an ENUM, because it grows // with almost every later phase — so it has to actually refuse. restoreOriginals() const ok = await logDb.write({ runId: 1, kind: 'not.a.kind' }) assert.equal(ok, false) installStubs() }) // ── Archive ──────────────────────────────────────────────────────────────── test('archiving is refused while a run is in flight, and allowed once it is not', async () => { const created = await createDraft() const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const started = await call(ctrl.startRun, { params: { id: String(id) } }) const refused = await call(ctrl.archive, { params: { id: String(id) } }) assert.equal(refused.statusCode, 409) assert.match(refused.body.errors.join('\n'), /still in flight/) store.runs.get(started.body.run.id).status = 'completed' const res = await call(ctrl.archive, { params: { id: String(id) } }) assert.equal(res.statusCode, 200) assert.equal(res.body.event.state, 'archived') }) test('an archived definition can be neither edited nor published', async () => { const created = await createDraft() const id = created.body.event.id await call(ctrl.archive, { params: { id: String(id) } }) const edited = await call(ctrl.update, { params: { id: String(id) }, body: draftBody() }) assert.equal(edited.statusCode, 409) const published = await call(ctrl.publish, { params: { id: String(id) } }) assert.equal(published.statusCode, 409) }) test('the list filters by state, and an unknown id is 404 rather than 500', async () => { await createDraft() const second = await createDraft({ title: 'A Second Event' }) await call(ctrl.publish, { params: { id: String(second.body.event.id) } }) const ready = await call(ctrl.list, { query: { state: 'ready' } }) assert.deepEqual(ready.body.events.map((e) => e.title), ['A Second Event']) const missing = await call(ctrl.get, { params: { id: '9999' } }) assert.equal(missing.statusCode, 404) const bad = await call(ctrl.get, { params: { id: 'not-a-number' } }) assert.equal(bad.statusCode, 400) }) test('publishing re-pins the occurrences that have not started, and says how many', async () => { // The case an operator meets on their SECOND edit of any recurring event: a // fortnight of occurrences is already on the calendar, each carrying the spec // as it was. Left alone, an edit reaches none of them and the only recourse -- // cancelling each -- makes the occurrence vanish rather than come back, because // a cancelled row still holds its slot in `uq_evrun_occurrence`. const created = await createDraft() const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const ahead = await call(ctrl.startRun, { params: { id: String(id) }, body: { scope: 'ahead', scheduledFor: '2026-12-24T20:00:00Z' }, }) assert.equal(ahead.statusCode, 201) const aheadId = ahead.body.run.id const v1 = store.runs.get(aheadId).version_id // A second occurrence, this one already under way. Its pin is what makes it // explicable afterwards, so it must not move. const inFlight = await call(ctrl.startRun, { params: { id: String(id) }, body: { scope: 'inflight', scheduledFor: '2026-12-25T20:00:00Z' }, }) const inFlightId = inFlight.body.run.id store.runs.get(inFlightId).status = 'running' store.runs.get(inFlightId).started_at = new Date() const republished = await call(ctrl.publish, { params: { id: String(id) } }) assert.equal(republished.statusCode, 200) assert.equal(republished.body.version, 2) assert.equal(republished.body.repinned, 1) assert.equal(store.runs.get(aheadId).version_id, republished.body.versionId) assert.notEqual(store.runs.get(aheadId).version_id, v1) assert.equal(store.runs.get(inFlightId).version_id, v1) // The move is on the run's own log, because "which version did this actually // use" is the first question an audit asks. const line = store.log.find((l) => l.run_id === aheadId && l.detail?.repinned) assert.equal(line.detail.fromVersionId, v1) assert.equal(line.detail.toVersionId, republished.body.versionId) }) test('re-publishing with nothing scheduled ahead re-pins nothing', async () => { const created = await createDraft() const id = created.body.event.id await call(ctrl.publish, { params: { id: String(id) } }) const again = await call(ctrl.publish, { params: { id: String(id) } }) assert.equal(again.body.repinned, 0) })