feat(events): schema, CRUD and the core action registry (Phase 1)
EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not
depend on the module contract — plus definitions CRUD, publish, archive, and
the action registry with core as its first registrant.
**Nothing dispatches.** There is no runner until Phase 2, so a run row is
created and stays `scheduled`. That is this phase's correct answer and the
surface renders it verbatim rather than hiding it.
Schema (`db/schema.sql`, append-only):
event_series, event_definitions, event_versions, event_runs,
event_run_steps, event_run_log. The four that need a writer —
event_action_settings, event_run_budget, event_run_resources,
event_run_participants — arrive with the phases that give them one.
Registry (`modules/registries.js` + `config/coreEventActions.js`):
registerEventActions staging and commit, with its own id namespace, the
closed risk and reversibility sets, revert() required iff and only iff
reversible: 'ledger', a bounded budgetMs and a param shape whose every
entry needs a type and an example. perform/revert/cost are stripped from
everything the catalog serves. Core declares core.announce, core.wait and
core.cue through the same staging area a module will use.
It is reachable ONLY by registerCore(): loader.js builds its own api facade
and has no method that delegates here, so no module can call it and
MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump.
Surface (13 routes under /api/v1/admin/events):
Reads staff-wide; publish, archive and run creation admin-only from this
phase per EVENTS.md §N2, even though the switchboard they will consult does
not exist yet — a button that is admin-only later and open now is a gate
nobody notices was missing. The live run controls and `verify` are absent
rather than stubbed, because nothing is in flight yet.
Four things the build settled, all recorded in docs:
- event_definitions gained a `spec` column. A draft's working copy cannot
be an event_versions row: that table is immutable and a run pins one.
- The spec validator must accept its own output. It added `actionVersion`
and `dormant` and then refused them as unknown keys, which would have made
the second save of any definition — and publish's re-validation —
impossible. A test caught it; both are now accepted and recomputed.
- A param's `example` is required, optional params included, matching
registerEventTriggers. It is the authoring form's placeholder.
- Two routes the §API-surface table did not name: GET /admin/events/:id and
GET /admin/events/series.
Core's three perform() bodies answer { ok: false, retry: false } rather than
{ ok: true }: `ok: true` on an action that did nothing is a recorded world
change that did not occur, which is the exact mistake §F's failure default
exists to prevent.
`conditions.checkLiteral` is exported and reused for step-param type checking
— one switch over the six types, so "is this a datetime" has one answer.
Verified: 44 new tests, whole server suite, `npm run check:modules`, routes
manifest and swagger regenerated (the manifest diff is +13 routes, zero moved).
Docs: RunicGateway/docs#209
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
196
server/test/eventActionRegistry.test.js
Normal file
196
server/test/eventActionRegistry.test.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── The event action registry (EVENTS.md §F, Phase 1) ──────────────────────
|
||||
//
|
||||
// Phase 1's acceptance criteria for the registry half, one test apiece:
|
||||
//
|
||||
// • core's three actions register on every boot and appear in the catalog
|
||||
// • the catalog never carries a callable — no `perform`, `revert` or `cost`
|
||||
// • a module registering an un-namespaced action fails, with the holder named
|
||||
// • the closed sets are closed: an invented risk or reversibility is refused
|
||||
// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION,
|
||||
// not discovered at teardown when something has already been created
|
||||
// • an action id and a trigger id are DIFFERENT namespaces, so one id may
|
||||
// legitimately be both — the property the audience registry established and
|
||||
// this one inherits
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything: registries.js reaches
|
||||
// utils/discordAnnounce, which reaches the pool at require time.
|
||||
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 coreEventActions = require('../src/config/coreEventActions')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
afterEach(() => registries._reset())
|
||||
|
||||
const ok = (over = {}) => ({
|
||||
id: 'demo.thing.do',
|
||||
label: 'Do the thing',
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
perform: async () => ({ ok: true }),
|
||||
...over,
|
||||
})
|
||||
|
||||
const register = (owner, entries) => {
|
||||
const api = registries.stage(owner)
|
||||
api.registerEventActions(entries)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
test('core registers its three actions on every boot', () => {
|
||||
registries.registerCore()
|
||||
const ids = registries.allEventActions().map((a) => a.id)
|
||||
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue'])
|
||||
assert.equal(ids.length, coreEventActions.ACTIONS.length)
|
||||
})
|
||||
|
||||
test('the catalog carries no callable', () => {
|
||||
registries.registerCore()
|
||||
for (const action of registries.allEventActions()) {
|
||||
assert.equal(action.perform, undefined, `${action.id} leaked perform`)
|
||||
assert.equal(action.revert, undefined, `${action.id} leaked revert`)
|
||||
assert.equal(action.cost, undefined, `${action.id} leaked cost`)
|
||||
}
|
||||
// …and the runner's own lookup still has it, which is the half that makes the
|
||||
// stripping a boundary rather than a deletion.
|
||||
assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
|
||||
})
|
||||
|
||||
test('core placeholders refuse rather than claiming success', async () => {
|
||||
// Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters
|
||||
// because `ok: true` on an action that did nothing is a recorded world change
|
||||
// that did not occur — the one wrong answer a stub can give.
|
||||
registries.registerCore()
|
||||
for (const id of ['core.announce', 'core.wait', 'core.cue']) {
|
||||
const answer = await registries.eventAction(id).perform({})
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, false)
|
||||
assert.match(answer.error, new RegExp(id.replace('.', '\\.')))
|
||||
}
|
||||
})
|
||||
|
||||
test('an action must be namespaced to its owner, and the holder is named', () => {
|
||||
assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/)
|
||||
|
||||
register('demo', [ok()])
|
||||
assert.throws(
|
||||
() => register('rival', [ok({ id: 'demo.thing.do' })]),
|
||||
/already registered by "demo"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('the same id twice in one batch is refused', () => {
|
||||
assert.throws(() => register('demo', [ok(), ok()]), /registered twice/)
|
||||
})
|
||||
|
||||
test('risk and reversibility are closed sets with no default', () => {
|
||||
assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/)
|
||||
assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/)
|
||||
assert.throws(
|
||||
() => register('demo', [ok({ reversible: undefined })]),
|
||||
/needs a reversible class/,
|
||||
)
|
||||
assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/)
|
||||
})
|
||||
|
||||
test("reversible: 'ledger' without revert() is refused at registration", () => {
|
||||
assert.throws(
|
||||
() => register('demo', [ok({ reversible: 'ledger' })]),
|
||||
/is reversible: 'ledger' but has no revert\(\)/,
|
||||
)
|
||||
// And the mirror: a revert() nothing will ever call is a promise core does not
|
||||
// keep, so it is refused just as loudly.
|
||||
assert.throws(
|
||||
() => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]),
|
||||
/declares revert\(\) but is reversible: 'none'/,
|
||||
)
|
||||
register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })])
|
||||
assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function')
|
||||
})
|
||||
|
||||
test('perform() is required and cost must be a function', () => {
|
||||
assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/)
|
||||
assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
|
||||
})
|
||||
|
||||
test('every param needs a type and an example', () => {
|
||||
const withParams = (params) => ok({ params })
|
||||
assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/)
|
||||
assert.throws(
|
||||
() => register('demo', [withParams([{ name: 'x', type: 'int' }])]),
|
||||
/needs an example/,
|
||||
)
|
||||
assert.throws(
|
||||
() => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]),
|
||||
/bad param name/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
register('demo', [
|
||||
withParams([
|
||||
{ name: 'x', type: 'int', example: 1 },
|
||||
{ name: 'x', type: 'int', example: 2 },
|
||||
]),
|
||||
]),
|
||||
/declared twice/,
|
||||
)
|
||||
register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])])
|
||||
const [param] = registries.eventAction('demo.thing.do').params
|
||||
assert.deepEqual(param, {
|
||||
name: 'x',
|
||||
type: 'int',
|
||||
required: false,
|
||||
example: 12,
|
||||
source: 'demo.options.x',
|
||||
description: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('budgetMs defaults, and is bounded', () => {
|
||||
register('demo', [ok()])
|
||||
assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS)
|
||||
registries._reset()
|
||||
assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/)
|
||||
assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/)
|
||||
})
|
||||
|
||||
test('actions and triggers are different namespaces, so one id may be both', () => {
|
||||
// The property §F states and the audience registry established first. A verb
|
||||
// called `demo.raid.start` and an event called `demo.raid.start` are two
|
||||
// unrelated declarations, and forbidding the pair would forbid the most
|
||||
// natural names a module will ever want.
|
||||
const api = registries.stage('demo')
|
||||
api.registerEventTriggers([
|
||||
{ id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' },
|
||||
])
|
||||
api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })])
|
||||
registries.apply(api.staged)
|
||||
|
||||
assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started')
|
||||
assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid')
|
||||
})
|
||||
|
||||
test('a whole batch is refused or taken, never half', () => {
|
||||
assert.throws(
|
||||
() => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]),
|
||||
/needs a risk class/,
|
||||
)
|
||||
// The shape check throws at the CALL, before anything is staged, so nothing
|
||||
// from the batch is visible.
|
||||
assert.equal(registries.eventAction('demo.thing.do'), null)
|
||||
})
|
||||
|
||||
test('_reset() hands the process back', () => {
|
||||
registries.registerCore()
|
||||
assert.equal(registries.allEventActions().length, 3)
|
||||
registries._reset()
|
||||
assert.equal(registries.allEventActions().length, 0)
|
||||
assert.equal(registries.isEventAction('core.wait'), false)
|
||||
})
|
||||
231
server/test/eventSpec.test.js
Normal file
231
server/test/eventSpec.test.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// ── The event spec validator (EVENTS.md §C/§D, Phase 1) ────────────────────
|
||||
//
|
||||
// The boundary that decides whether a version may exist. Its interesting cases
|
||||
// are all about time rather than shape:
|
||||
//
|
||||
// • a step's params are checked against the action's DECLARED params, and the
|
||||
// action version it was authored against is captured at save
|
||||
// • `on_failure` is defaulted from the risk class, because a `change` action
|
||||
// that fell back to `skip` would advance a run over a half-changed world
|
||||
// • an unregistered action is refused on a NEW step and KEPT on an existing
|
||||
// one — the rule `engagementRules.model` established for a dormant trigger,
|
||||
// for the same reason: an uninstall must not be destructive after the fact
|
||||
// • a dormant step blocks a PUBLISH and never a SAVE
|
||||
// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)`
|
||||
// would silently collapse them into one at materialisation
|
||||
// • a key a later phase owns (`advance`, `announcements`) is REFUSED rather
|
||||
// than preserved, so no corpus of unvalidated specs accumulates
|
||||
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 spec = require('../src/events/spec')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
beforeEach(() => {
|
||||
registries._reset()
|
||||
registries.registerCore()
|
||||
const api = registries.stage('demo')
|
||||
api.registerEventActions([
|
||||
{
|
||||
id: 'demo.world.change',
|
||||
label: 'Change the world',
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 4,
|
||||
params: [
|
||||
{ name: 'region', type: 'string', required: true, example: 'Yew' },
|
||||
{ name: 'count', type: 'int', required: true, example: 12 },
|
||||
{ name: 'hue', type: 'int', required: false, example: 1157 },
|
||||
],
|
||||
perform: async () => ({ ok: true }),
|
||||
},
|
||||
{
|
||||
id: 'demo.world.wreck',
|
||||
label: 'Wreck the world',
|
||||
risk: 'irreversible',
|
||||
reversible: 'none',
|
||||
perform: async () => ({ ok: true }),
|
||||
},
|
||||
])
|
||||
registries.apply(api.staged)
|
||||
})
|
||||
afterEach(() => registries._reset())
|
||||
|
||||
const oneStep = (step) => ({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [step] }],
|
||||
})
|
||||
|
||||
test('the empty spec is valid, and is what a new draft carries', () => {
|
||||
const result = spec.validate(spec.emptySpec())
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.spec.phases.length, 1)
|
||||
assert.deepEqual(result.spec.schedule, { kind: 'manual' })
|
||||
})
|
||||
|
||||
test('params are checked against the declaration and coerced', () => {
|
||||
const result = spec.validate(
|
||||
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 12 } }),
|
||||
)
|
||||
assert.equal(result.ok, true)
|
||||
const [step] = result.spec.phases[0].steps
|
||||
assert.deepEqual(step.params, { region: 'Yew', count: 12 })
|
||||
// Captured from the declaration, not from the request: it is what lets a later
|
||||
// bump warn in the editor instead of dispatching a mistyped parameter.
|
||||
assert.equal(step.actionVersion, 4)
|
||||
assert.equal(step.dormant, false)
|
||||
})
|
||||
|
||||
test('a missing required param, a wrong type and an unknown param are all refused', () => {
|
||||
const missing = spec.validate(oneStep({ actionId: 'demo.world.change', params: { region: 'Yew' } }))
|
||||
assert.equal(missing.ok, false)
|
||||
assert.match(missing.errors.join('\n'), /"count" is required/)
|
||||
|
||||
const wrong = spec.validate(
|
||||
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 'twelve' } }),
|
||||
)
|
||||
assert.equal(wrong.ok, false)
|
||||
assert.match(wrong.errors.join('\n'), /"count" expected an integer/)
|
||||
|
||||
// An unknown param is an ERROR, not a silent drop: an author who typed
|
||||
// `regions` has written a step that would dispatch with the region missing,
|
||||
// and dropping the key makes that look like it saved cleanly.
|
||||
const typo = spec.validate(
|
||||
oneStep({ actionId: 'demo.world.change', params: { regions: 'Yew', count: 1 } }),
|
||||
)
|
||||
assert.equal(typo.ok, false)
|
||||
assert.match(typo.errors.join('\n'), /"regions" is not a param of demo\.world\.change/)
|
||||
})
|
||||
|
||||
test('on_failure is defaulted from the risk class', () => {
|
||||
const notify = spec.validate(
|
||||
oneStep({ actionId: 'core.announce', params: { leg: 'discord', body: 'hi' } }),
|
||||
)
|
||||
assert.equal(notify.spec.phases[0].steps[0].onFailure, 'skip')
|
||||
|
||||
const change = spec.validate(
|
||||
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 1 } }),
|
||||
)
|
||||
assert.equal(change.spec.phases[0].steps[0].onFailure, 'pause')
|
||||
|
||||
const irreversible = spec.validate(oneStep({ actionId: 'demo.world.wreck' }))
|
||||
assert.equal(irreversible.spec.phases[0].steps[0].onFailure, 'abort_run')
|
||||
|
||||
// An author may still choose, within the closed set.
|
||||
const chosen = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'skip' }))
|
||||
assert.equal(chosen.spec.phases[0].steps[0].onFailure, 'skip')
|
||||
const invented = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'shrug' }))
|
||||
assert.equal(invented.ok, false)
|
||||
assert.match(invented.errors.join('\n'), /onFailure: must be one of/)
|
||||
})
|
||||
|
||||
test('a NEW step may not name an unregistered action', () => {
|
||||
const result = spec.validate(oneStep({ actionId: 'gone.module.verb' }))
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), /no module registers "gone\.module\.verb"/)
|
||||
})
|
||||
|
||||
test('an EXISTING step keeps its action when the module goes away, and is marked dormant', () => {
|
||||
const saved = spec.validate(
|
||||
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }),
|
||||
).spec
|
||||
|
||||
// The module is uninstalled between one save and the next.
|
||||
registries._reset()
|
||||
registries.registerCore()
|
||||
|
||||
const again = spec.validate(saved, { knownActionIds: spec.actionIdsIn(saved) })
|
||||
assert.equal(again.ok, true, again.errors && again.errors.join('\n'))
|
||||
const [step] = again.spec.phases[0].steps
|
||||
assert.equal(step.dormant, true)
|
||||
// Params pass through untouched: the only thing that could validate them left
|
||||
// with the module.
|
||||
assert.deepEqual(step.params, { region: 'Yew', count: 3 })
|
||||
|
||||
// …and that is exactly what publish refuses.
|
||||
const publishable = spec.publishable(again.spec)
|
||||
assert.equal(publishable.ok, false)
|
||||
assert.deepEqual(publishable.dormant, ['demo.world.change'])
|
||||
})
|
||||
|
||||
test('validate accepts its own output — a saved spec is re-validated on every save', () => {
|
||||
// The property the dormancy test above found the hard way: `validate` adds
|
||||
// `actionVersion` and `dormant`, and a validator that then refused its own
|
||||
// fields would make the SECOND save of any definition impossible, and publish
|
||||
// — which re-validates before snapshotting — impossible full stop.
|
||||
const once = spec.validate(
|
||||
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }),
|
||||
)
|
||||
const twice = spec.validate(once.spec)
|
||||
assert.equal(twice.ok, true, twice.errors && twice.errors.join('\n'))
|
||||
assert.deepEqual(twice.spec, once.spec)
|
||||
})
|
||||
|
||||
test('two phases may not share a key', () => {
|
||||
const result = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'main', label: 'One', steps: [] },
|
||||
{ key: 'main', label: 'Two', steps: [] },
|
||||
],
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), /used by more than one phase/)
|
||||
})
|
||||
|
||||
test('a key a later phase owns is refused, not silently preserved', () => {
|
||||
const top = spec.validate({ schedule: { kind: 'manual' }, phases: [], announcements: [] })
|
||||
assert.equal(top.ok, false)
|
||||
assert.match(top.errors.join('\n'), /unknown key "announcements"/)
|
||||
|
||||
const phase = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m' } }],
|
||||
})
|
||||
assert.equal(phase.ok, false)
|
||||
assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/)
|
||||
})
|
||||
|
||||
test('only the manual schedule exists in this phase', () => {
|
||||
const weekly = spec.validate({
|
||||
schedule: { kind: 'weekly', days: ['fri'], time: '20:00' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
})
|
||||
assert.equal(weekly.ok, false)
|
||||
assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/)
|
||||
})
|
||||
|
||||
test('every problem is reported, not just the first', () => {
|
||||
const result = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'BAD KEY', label: '', steps: [{ actionId: 'demo.world.change', params: {} }] },
|
||||
],
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
const joined = result.errors.join('\n')
|
||||
assert.match(joined, /bad phase key/)
|
||||
assert.match(joined, /a phase needs a label/)
|
||||
assert.match(joined, /"region" is required/)
|
||||
assert.match(joined, /"count" is required/)
|
||||
})
|
||||
|
||||
test('the size bounds hold', () => {
|
||||
const many = {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: Array.from({ length: spec.MAX_PHASES + 1 }, (_, i) => ({
|
||||
key: `p${i}`,
|
||||
label: `P${i}`,
|
||||
steps: [],
|
||||
})),
|
||||
}
|
||||
const result = spec.validate(many)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), new RegExp(`at most ${spec.MAX_PHASES} phases`))
|
||||
})
|
||||
597
server/test/eventsAdmin.test.js
Normal file
597
server/test/eventsAdmin.test.js
Normal file
@@ -0,0 +1,597 @@
|
||||
// ── 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.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)
|
||||
})
|
||||
Reference in New Issue
Block a user