// ── 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.announce refuses an unregistered leg terminally, and never claims success', async () => { // Phase 1's version of this test asserted that all three core actions REFUSED, // because none of them was wired yet. Phase 2 gave them real bodies, so what // survives is the half that was never about the placeholder: `ok: true` on an // action that did nothing is a recorded world change that did not occur. // // `core.announce` is the one that can still legitimately refuse. A leg nobody // registers will not appear between two attempts a minute apart, so the answer // is terminal rather than transient — a human has to fix it. registries.registerCore() const answer = await registries.eventAction('core.announce').perform({ params: { leg: 'nowhere', body: 'hello' }, }) assert.equal(answer.ok, false) assert.equal(answer.retry, false) assert.match(answer.error, /nowhere/) }) test('a dry run validates and reports, but dispatches nothing', async () => { // §I's dry run: `verify === true` means validate and report, change nothing. // `core.announce` is the only core action with an outside effect to suppress. // // **The leg is resolved BEFORE `verify` is honoured, and that ordering is the // point rather than an oversight.** A dry run exists to report what would // happen, and "this step names a leg nobody registers" is the most useful thing // it can find. Answering `ok: true` first would make the dry run pass on // exactly the definition that cannot work. registries.registerCore() const announce = registries.eventAction('core.announce') const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true }) assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist') // A registered leg: reported good, and its transport never touched. const leg = registries.announceLeg('discord') const dispatch = leg.dispatch let dispatched = 0 leg.dispatch = async () => { dispatched += 1 return { ok: true } } try { const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true }) assert.equal(good.ok, true) assert.equal(dispatched, 0, 'a dry run sends nothing') } finally { leg.dispatch = dispatch } }) test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => { // Both answer through ordinary envelope members, which is what lets the runner // honour them without knowing what either action is. A `perform` that slept // would hold its claim for the duration and turn a five-minute pause into a // five-minute lease. registries.registerCore() assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), { ok: true, holdFor: 300, }) assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), { ok: true, await: 'human', }) }) 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) })