`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1119 lines
48 KiB
JavaScript
1119 lines
48 KiB
JavaScript
// ── 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')
|
|
// Phase 6: the run console reads the budget meter and the switchboard route
|
|
// reads the settings table. Same rule as Phases 4 and 5 -- a new leg under a
|
|
// model needs a stub in every file that stubs that layer.
|
|
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
|
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
|
// Phase 8: the run console reads the resource ledger. The SAME rule, for the
|
|
// fourth time in this feature -- Phase 4's expansion leg, Phase 5's gate read,
|
|
// Phase 6's settings read and now this one. **Unstubbed it is not a wrong
|
|
// answer, it is a ten-second ECONNREFUSED against the dead port**, which is why
|
|
// one missing stub here cost the run detail test ten seconds and said nothing
|
|
// about the route it was testing.
|
|
const resourcesDb = require('../src/model/events/eventRunResources.db')
|
|
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
|
const seriesDb = require('../src/model/events/eventSeries.db')
|
|
const gatesDb = require('../src/model/events/eventPhaseGates.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],
|
|
['gatesDb', gatesDb],
|
|
['settingsDb', settingsDb],
|
|
['budgetDb', budgetDb],
|
|
['resourcesDb', resourcesDb],
|
|
['participantsDb', participantsDb],
|
|
['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(),
|
|
gates: [],
|
|
settings: new Map(),
|
|
budget: new Map(),
|
|
resources: [],
|
|
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,
|
|
// Phase 6: joined in `SELECT_LIST` alongside `current_version`, and it has to
|
|
// be joined HERE too or the stub answers a shape the real query never
|
|
// returns — which is a test agreeing with itself rather than with the server.
|
|
current_version_verified_at: store.versions.get(d.current_version_id)?.verified_at ?? 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,
|
|
// Phase 6. A version is unverified the moment it is cut, which is what
|
|
// makes §K's gate mean anything: publishing is not the review.
|
|
verified_at: null,
|
|
verified_by: null,
|
|
})
|
|
return id
|
|
}
|
|
versionsDb.markVerified = async (id, userId, at = new Date()) => {
|
|
const v = store.versions.get(id)
|
|
if (!v) return false
|
|
Object.assign(v, { verified_at: at, verified_by: userId })
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Phase 5 gave `runs.detail()` a third leg, and an unstubbed one is a real
|
|
// query against the dead port this file points at: the run-console test hung
|
|
// for ten seconds and then failed with ECONNREFUSED, saying nothing whatever
|
|
// about the route. **This is the third time a new leg has caught a stubbing
|
|
// file out** — Phase 4's expansion leg did it to `eventRunner.test.js`, where
|
|
// it merely made the file slow. Worth the comment: when the runner or a model
|
|
// gains a leg, every file that stubs the layer under it needs the stub.
|
|
gatesDb.listForRun = async (runId) => store.gates.filter((g) => g.run_id === runId)
|
|
|
|
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
|
|
}
|
|
|
|
// ── Phase 6's two tables ──
|
|
//
|
|
// `store.settings` is a real store here rather than an empty stand-in, because
|
|
// this file tests the switchboard ROUTES: the GET has to be able to tell a
|
|
// stored opinion from a risk-class default, and the PUT has to be readable
|
|
// back.
|
|
settingsDb.all = async () => [...store.settings.values()]
|
|
settingsDb.get = async (actionId) => store.settings.get(actionId) || null
|
|
settingsDb.byIds = async (ids) =>
|
|
new Map(
|
|
[...new Set(ids || [])]
|
|
.filter((id) => store.settings.has(id))
|
|
.map((id) => [id, store.settings.get(id)]),
|
|
)
|
|
settingsDb.put = async (actionId, { enabled, caps }, userId = null) => {
|
|
const row = {
|
|
action_id: actionId,
|
|
enabled: enabled ? 1 : 0,
|
|
caps: caps || {},
|
|
updated_by: userId,
|
|
updated_at: new Date(),
|
|
}
|
|
store.settings.set(actionId, row)
|
|
return row
|
|
}
|
|
|
|
budgetDb.seed = async (runId, dimensions) => {
|
|
for (const [dimension, d] of Object.entries(dimensions || {})) {
|
|
const key = `${runId}:${dimension}`
|
|
if (!store.budget.has(key)) {
|
|
store.budget.set(key, { run_id: runId, dimension, consumed: 0, cap: d.cap, effective_from: d.from || null })
|
|
}
|
|
}
|
|
return Object.keys(dimensions || {}).length
|
|
}
|
|
budgetDb.forRun = async (runId) =>
|
|
[...store.budget.values()]
|
|
.filter((b) => Number(b.run_id) === Number(runId))
|
|
.sort((a, b) => a.dimension.localeCompare(b.dimension))
|
|
|
|
resourcesDb.forRun = async (runId) =>
|
|
store.resources.filter((r) => Number(r.run_id) === Number(runId))
|
|
|
|
// Phase 10: a run's detail now carries its participants. Unstubbed this is the
|
|
// ten-second dead-port wait, for the sixth time in this feature — and it would
|
|
// 500 the run console rather than saying anything about the route.
|
|
store.participants = []
|
|
participantsDb.listForRun = async (runId) =>
|
|
store.participants.filter((p) => Number(p.run_id) === Number(runId))
|
|
}
|
|
|
|
// ── 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),
|
|
// Phase 10's two are the integrations (EVENTS.md §J): `core.announce.post`
|
|
// sends an article through the announce legs rather than a line, and
|
|
// `core.results.publish` ranks and stamps the run's participants.
|
|
['core.announce', 'core.wait', 'core.cue', 'core.lease', 'core.announce.post', 'core.results.publish'],
|
|
)
|
|
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'])
|
|
// The other three registrations of the module contract arrived in Phase 7, and
|
|
// they are served BESIDE the actions because the editor needs all four to draw
|
|
// one step. Core declares no budgets and no leases of its own — its four
|
|
// actions cost nothing, and `core.lease` BORROWS a lease rather than declaring
|
|
// one, which is the seam working the way round it is meant to: core owns the
|
|
// verb, a module owns the value. So both are empty here, and that is the fact
|
|
// worth asserting: present and empty, not absent.
|
|
assert.deepEqual(res.body.budgets, [])
|
|
assert.deepEqual(res.body.leases, [])
|
|
// Two option sources, both core's: `core.announce`'s leg and `core.lease`'s
|
|
// lease. They are here WITHOUT their resolvers — the values are a request of
|
|
// their own.
|
|
assert.deepEqual(
|
|
res.body.optionSources.map((s) => s.id),
|
|
['core.options.legs', 'core.options.leases', 'core.options.posts'],
|
|
)
|
|
for (const s of res.body.optionSources) assert.equal(s.resolve, undefined)
|
|
})
|
|
|
|
test('an option source resolves its values, and a refusal is a 200 the form can render', async () => {
|
|
// §F: a source that cannot answer degrades its field to free text with a
|
|
// visible warning rather than blocking the form, so BOTH answers are 200s and
|
|
// the difference is `ok`. A 4xx here would make an authoring screen something a
|
|
// module's outage can take away.
|
|
const ok = await call(ctrl.options, { params: { sourceId: 'core.options.legs' }, user: ADMIN })
|
|
assert.equal(ok.statusCode, 200)
|
|
assert.equal(ok.body.ok, true)
|
|
// Whatever legs this boot registered, each is a { value, label } pair — core
|
|
// renders no game word, so a leg's own label is the only text on the option.
|
|
for (const option of ok.body.options) {
|
|
assert.equal(typeof option.value, 'string')
|
|
assert.equal(typeof option.label, 'string')
|
|
}
|
|
|
|
const missing = await call(ctrl.options, { params: { sourceId: 'nobody.at.all' }, user: ADMIN })
|
|
assert.equal(missing.statusCode, 200)
|
|
assert.equal(missing.body.ok, false)
|
|
assert.match(missing.body.reason, /no module registers/)
|
|
})
|
|
|
|
// ── 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)
|
|
})
|
|
|
|
// ── Phase 6: the switchboard, the dry run, and the role floor on a step ────
|
|
//
|
|
// `eventAuthorize.test.js` holds `mayInvoke`'s layers and `eventVerify.test.js`
|
|
// holds the dry run's report. What is genuinely new HERE is what the surface
|
|
// decides on top of them: what the board serves when nobody has ever touched it,
|
|
// what a cap is allowed to name, which spec a dry run is run against, and the one
|
|
// gate that cannot live in route middleware because it depends on the BODY.
|
|
|
|
const ADMIN = { id: 1, role: 'admin' }
|
|
const EDITOR = { id: 2, role: 'editor' }
|
|
|
|
/**
|
|
* A module whose action declares a COST, which `demo.world.change` does not.
|
|
*
|
|
* Separate rather than folded into `registerDemoModule`, because the two answer
|
|
* different questions: that one is "a world-changing verb exists", this one is "a
|
|
* verb that spends something exists", and the switchboard's cap editor only has
|
|
* anything to render for the second.
|
|
*/
|
|
function registerCosting() {
|
|
// The owner must match the id's namespace: the registry refuses an action id
|
|
// that is not prefixed with the module registering it, which is what keeps an
|
|
// action's id space its own (§F).
|
|
const api = registries.stage('test')
|
|
// The dimension is DECLARED as well as priced (Phase 7): a `cost()` naming a
|
|
// dimension no module registers is refused at save and at dispatch, so an
|
|
// action that priced one without declaring it could never be put in a step.
|
|
api.registerEventBudgets([{ id: 'test.creatures', label: 'Creatures', unit: 'count' }])
|
|
api.registerEventActions([
|
|
{
|
|
id: 'test.spawn',
|
|
label: 'Spawn creatures',
|
|
risk: 'change',
|
|
reversible: 'none',
|
|
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
|
cost: (p) => ({ 'test.creatures': p.count }),
|
|
perform: async () => ({ ok: true }),
|
|
},
|
|
])
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
// ── The switchboard ────────────────────────────────────────────────────────
|
|
|
|
test('the board serves every registered action with its risk-class default, and says nothing is configured', async () => {
|
|
// A fresh deployment has no rows at all — nothing is seeded at boot, because
|
|
// registration runs against a dead pool (MODULE_API §2.2) — so the board's
|
|
// first render is entirely computed. `configured: false` is how the screen
|
|
// tells "an admin turned this on" from "this has always been on".
|
|
const res = await call(ctrl.actions, { user: ADMIN })
|
|
assert.equal(res.statusCode, 200)
|
|
|
|
const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a]))
|
|
assert.deepEqual(Object.keys(byId).sort(), [
|
|
'core.announce',
|
|
'core.announce.post',
|
|
'core.cue',
|
|
'core.lease',
|
|
'core.results.publish',
|
|
'core.wait',
|
|
])
|
|
// `core.results.publish` is `inspect`, so like `core.wait` it arrives ENABLED:
|
|
// nothing in the game world changes and nobody is messaged, and an author
|
|
// should be able to place it without an admin first visiting this screen.
|
|
assert.equal(byId['core.results.publish'].enabled, true)
|
|
assert.equal(byId['core.results.publish'].changesWorld, false)
|
|
// And `core.lease` is the one core action the default-off rule bites: it is
|
|
// `change`, so a fresh deployment cannot borrow a value until an admin says so.
|
|
// §K's sentence, applied to core's own verb rather than only to a module's.
|
|
assert.equal(byId['core.lease'].enabled, false)
|
|
assert.equal(byId['core.lease'].changesWorld, true)
|
|
// core.wait is `inspect`, and it arrives ENABLED. Read §K's sentence literally
|
|
// and it would not, and every published event that waits would break on a fresh
|
|
// deployment (org lead, 2026-09-03).
|
|
assert.equal(byId['core.wait'].enabled, true)
|
|
assert.equal(byId['core.wait'].changesWorld, false)
|
|
assert.equal(byId['core.announce'].enabled, true)
|
|
for (const a of res.body.actions) assert.equal(a.configured, false)
|
|
assert.deepEqual(res.body.worldChangingRisks, ['change', 'irreversible'])
|
|
})
|
|
|
|
test('the board never serves a callable', async () => {
|
|
// `allEventActions()` strips `perform`, `revert` and `cost`. This board adds
|
|
// fields to that object, and adding them back by spreading the full
|
|
// registration would be how a module's function comes to leave the process.
|
|
const res = await call(ctrl.actions, { user: ADMIN })
|
|
for (const a of res.body.actions) {
|
|
assert.equal(a.perform, undefined)
|
|
assert.equal(a.revert, undefined)
|
|
assert.equal(a.reconcile, undefined)
|
|
assert.equal(a.cost, undefined)
|
|
}
|
|
})
|
|
|
|
test('a stored switch is served back, marked configured, with who set it', async () => {
|
|
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
|
|
const res = await call(ctrl.actions, { user: ADMIN })
|
|
const announce = res.body.actions.find((a) => a.id === 'core.announce')
|
|
assert.equal(announce.enabled, false)
|
|
assert.equal(announce.configured, true)
|
|
assert.ok(announce.updatedAt)
|
|
})
|
|
|
|
test('the switch works in both directions, because an operator must be able to turn things OFF', async () => {
|
|
const off = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: false } })
|
|
assert.equal(off.statusCode, 200)
|
|
assert.equal(off.body.action.enabled, false)
|
|
|
|
const on = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: true } })
|
|
assert.equal(on.body.action.enabled, true)
|
|
})
|
|
|
|
test('a switch for an action nobody registers is a 404, not a stored row', async () => {
|
|
// The board is rendered from the registry, so a write against something not in
|
|
// it is a client out of date — and storing it would put a row on the screen
|
|
// that no action can ever claim.
|
|
const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'gone.away', enabled: true } })
|
|
assert.equal(res.statusCode, 404)
|
|
})
|
|
|
|
test('enabled must be stated, because there is no safe value to guess', async () => {
|
|
const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce' } })
|
|
assert.equal(res.statusCode, 400)
|
|
assert.match(res.body.error, /enabled must be true or false/)
|
|
})
|
|
|
|
test('a cap must name a dimension the action actually spends', async () => {
|
|
// Not pedantry. A cap on a dimension an action never names is a number an
|
|
// operator believes is protecting them, rendered back to them for ever,
|
|
// bounding nothing. None of core's three actions declares a cost at all, so
|
|
// every cap is refused here — which is itself the honest state of a deployment
|
|
// with no module installed.
|
|
const res = await call(ctrl.saveAction, {
|
|
user: ADMIN,
|
|
body: { actionId: 'core.announce', enabled: true, caps: { 'uo.creatures': 30 } },
|
|
})
|
|
assert.equal(res.statusCode, 400)
|
|
assert.match(res.body.error, /does not spend "uo.creatures"/)
|
|
})
|
|
|
|
test('a cap that is not a whole number of 0 or more is refused', async () => {
|
|
registerCosting()
|
|
for (const bad of [-1, 2.5, 'lots']) {
|
|
const res = await call(ctrl.saveAction, {
|
|
user: ADMIN,
|
|
body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': bad } },
|
|
})
|
|
assert.equal(res.statusCode, 400, String(bad))
|
|
}
|
|
})
|
|
|
|
test('a cap of zero is legal, and it means zero', async () => {
|
|
// "This deployment permits this verb, and permits none of it" is a coherent
|
|
// thing to say, and refusing 0 would make an operator disable the action
|
|
// instead — which is a different fact with a different audit trail.
|
|
registerCosting()
|
|
const res = await call(ctrl.saveAction, {
|
|
user: ADMIN,
|
|
body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': 0 } },
|
|
})
|
|
assert.equal(res.statusCode, 200)
|
|
assert.deepEqual(res.body.action.caps, { 'test.creatures': 0 })
|
|
})
|
|
|
|
test('the board offers a cap box per dimension, named by its own declaration', async () => {
|
|
// WHICH dimensions an action spends is still discovered by pricing its declared
|
|
// examples — `cost` is a function of params, so calling it is the only honest
|
|
// way to ask. What Phase 7 added is what they are CALLED: the label and unit
|
|
// come from `registerEventBudgets`, because "30" on an unlabelled box is
|
|
// ambiguous in exactly the case that matters.
|
|
registerCosting()
|
|
const res = await call(ctrl.actions, { user: ADMIN })
|
|
const spawn = res.body.actions.find((a) => a.id === 'test.spawn')
|
|
assert.deepEqual(spawn.dimensions, [
|
|
{ id: 'test.creatures', label: 'Creatures', unit: 'count', registered: true },
|
|
])
|
|
assert.equal(spawn.enabled, false, 'a change action arrives disabled')
|
|
assert.equal(spawn.changesWorld, true)
|
|
})
|
|
|
|
// ── The dry run ────────────────────────────────────────────────────────────
|
|
|
|
test('a draft is verified against its working spec, and the pass is not recorded', async () => {
|
|
// There is no version to record it on, and a pass on a draft would be a claim
|
|
// about a spec that changes under the author's hands.
|
|
const { body } = await createDraft()
|
|
const res = await call(ctrl.verify, { user: EDITOR, params: { id: String(body.event.id) } })
|
|
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(res.body.target, 'draft')
|
|
assert.equal(res.body.versionId, null)
|
|
assert.equal(res.body.recorded, false)
|
|
assert.equal(res.body.report.ok, true)
|
|
assert.equal(res.body.report.steps, 1)
|
|
})
|
|
|
|
test('a ready definition is verified against the version that would actually run, and the pass IS recorded', async () => {
|
|
// §K's last bound. A version is immutable, so a dry run that passed against one
|
|
// stays true — which is what makes the pass a property of the version.
|
|
const { body } = await createDraft()
|
|
const id = body.event.id
|
|
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
|
|
|
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } })
|
|
assert.equal(res.body.target, 'version')
|
|
assert.equal(res.body.recorded, true)
|
|
assert.equal(res.body.version, 1)
|
|
|
|
// And the definition now says so, on the screen its author is already looking
|
|
// at rather than on the Friday it did not run.
|
|
const after = await call(ctrl.get, { user: ADMIN, params: { id: String(id) } })
|
|
assert.ok(after.body.event.currentVersionVerifiedAt)
|
|
})
|
|
|
|
test('a definition that has never been verified says so', async () => {
|
|
const { body } = await createDraft()
|
|
await call(ctrl.publish, { user: ADMIN, params: { id: String(body.event.id) } })
|
|
const res = await call(ctrl.get, { user: ADMIN, params: { id: String(body.event.id) } })
|
|
assert.equal(res.body.event.currentVersionVerifiedAt, null)
|
|
})
|
|
|
|
test('a report with findings is a 200, and it does not record a pass', async () => {
|
|
// The request succeeded; the plan has problems. A 4xx would make "this event
|
|
// asks for 45 and you allow 30" indistinguishable from "you sent a bad id",
|
|
// and rendering the findings is the whole value of the screen.
|
|
const { body } = await createDraft({
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }],
|
|
},
|
|
})
|
|
const id = body.event.id
|
|
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
|
|
|
// Switch the action off underneath the published version: the plan is now one
|
|
// this deployment will not carry out.
|
|
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
|
|
|
|
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } })
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(res.body.report.ok, false)
|
|
assert.equal(res.body.recorded, false, 'a failing dry run must not unlock a scheduled start')
|
|
assert.equal(res.body.report.findings[0].code, 'disabled')
|
|
})
|
|
|
|
test('an archived definition cannot be verified', async () => {
|
|
const { body } = await createDraft()
|
|
await call(ctrl.archive, { user: ADMIN, params: { id: String(body.event.id) } })
|
|
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(body.event.id) } })
|
|
assert.equal(res.statusCode, 409)
|
|
})
|
|
|
|
test('verifying a definition that does not exist is a 404', async () => {
|
|
const res = await call(ctrl.verify, { user: ADMIN, params: { id: '9999' } })
|
|
assert.equal(res.statusCode, 404)
|
|
})
|
|
|
|
// ── The role floor, which cannot live in route middleware ──────────────────
|
|
|
|
test('an editor cannot save a step whose action changes the world', async () => {
|
|
// §K's "any step whose action is above notify — admin only", with the line
|
|
// drawn between `inspect` and `change` (org lead, 2026-09-03). It is checked in
|
|
// the model rather than on the route because it depends on the BODY: the route
|
|
// is `admin, editor` and stays that way, and which of the two you have to be
|
|
// depends on what you put in the spec.
|
|
registerCosting()
|
|
const res = await call(ctrl.create, {
|
|
user: EDITOR,
|
|
body: draftBody({
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
|
|
},
|
|
}),
|
|
})
|
|
assert.equal(res.statusCode, 403)
|
|
assert.match(res.body.errors[0], /only an administrator may author a step/)
|
|
})
|
|
|
|
test('an editor may still save a step that only announces or waits', async () => {
|
|
// The other half, and the one the literal reading of §K would have broken: an
|
|
// editor who cannot author a step that waits has an authoring role that cannot
|
|
// author.
|
|
const res = await call(ctrl.create, { user: EDITOR, body: draftBody() })
|
|
assert.equal(res.statusCode, 201)
|
|
})
|
|
|
|
test('an admin may save the same world-changing step', async () => {
|
|
registerCosting()
|
|
const res = await call(ctrl.create, {
|
|
user: ADMIN,
|
|
body: draftBody({
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
|
|
},
|
|
}),
|
|
})
|
|
assert.equal(res.statusCode, 201)
|
|
})
|
|
|
|
test('the floor is checked on EDIT as well as on create', async () => {
|
|
// Otherwise an editor writes a legal draft and then edits a world-changing step
|
|
// into it, which is the same escalation with one more click.
|
|
registerCosting()
|
|
const { body } = await call(ctrl.create, { user: EDITOR, body: draftBody() })
|
|
const res = await call(ctrl.update, {
|
|
user: EDITOR,
|
|
params: { id: String(body.event.id) },
|
|
body: draftBody({
|
|
spec: {
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
|
|
},
|
|
}),
|
|
})
|
|
assert.equal(res.statusCode, 403)
|
|
})
|