// ── The four traps, as tests ────────────────────────────────────────────── // // `config/eventActions.js` marks four rules TRAP 1..4 and says all four are // invisible until an outage. That is a bad property for a rule to have and a good // reason to test it, because the alternative is finding out in production once. // // Each of the four gets a test that FAILS if the rule is broken — not one that // asserts the current value. Trap 1 in particular is asserted as an inequality // between two constants that live in different files, which is the only form that // survives somebody tuning the client. // // Everything here runs without core, without a database and without a game: the // declarations are plain objects and the client's transport is simulated. What it // cannot prove is that core accepts these declarations — a fake that agreed with // a mistake is exactly how a module ships green and refuses to load. That check // is `checkCoreApi.js` plus a run against a real core, and the kit's // `ci/core-ref.json` is where its date is written down. const test = require('node:test') const assert = require('node:assert') const { fakeCtx } = require('./_fakes') const core = require('../core') core.init(fakeCtx()) /* eslint-disable global-require */ const events = require('../config/eventActions') const sidecar = require('../sidecarClient') const clanDb = require('../model/clans/clanProvider.db') /* eslint-enable global-require */ // Stubbed at the `.db.js` seam, the same way `clanProvider.test.js` does it: // there is no database here, and an action's `verify` reads one. const CLANS = [{ externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 3 }] clanDb.listClans = async () => CLANS clanDb.findClan = async (externalId) => CLANS.find((c) => c.externalId === externalId) const action = events.ACTIONS.find((a) => a.id === 'examplegame.beacon.light') const lease = events.LEASES.find((l) => l.id === 'examplegame.rate.gather') /** A fresh key per call, the way core's is a function of a step's identity. */ let keyCounter = 0 const nextKey = () => `test-key-${(keyCounter += 1)}` // ══ Shape ═════════════════════════════════════════════════════════════════ test('every declaration is namespaced with the module id', () => { const ids = [ ...events.BUDGETS.map((b) => b.id), ...events.OPTION_SOURCES.map((s) => s.id), ...events.LEASES.map((l) => l.id), ...events.ACTIONS.map((a) => a.id), ] for (const id of ids) { assert.ok(id.startsWith('examplegame.'), `${id} is not namespaced — core refuses it`) } }) test('every param declares an example, optional ones included', () => { for (const a of events.ACTIONS) { for (const p of a.params) { assert.ok(p.example !== undefined, `${a.id}.${p.name} has no example`) } } }) test("an action's `source` names an option source this module registers", () => { // Core resolves this across every module, so a source another module owns is // legal. Checking the local case is still worth doing: a typo in your own id is // the overwhelmingly likely mistake, and it degrades the field to free text in // silence rather than failing. const sources = new Set(events.OPTION_SOURCES.map((s) => s.id)) for (const a of events.ACTIONS) { for (const p of a.params) { if (p.source && p.source.startsWith('examplegame.')) { assert.ok(sources.has(p.source), `${a.id}.${p.name} names an unregistered source`) } } } }) test("an action that ledgers declares `revert`", () => { for (const a of events.ACTIONS) { if (a.reversible === 'ledger') { assert.strictEqual(typeof a.revert, 'function', `${a.id} ledgers but cannot undo`) } } }) // ══ TRAP 1 — the failure default, and the budget that makes it reachable ══ test('TRAP 1: budgetMs strictly exceeds the client timeout', () => { // The inequality, not the value. Core classifies a budget timeout as a retry // WITHOUT asking the action, so if this ever inverts, every `retry: false` // below becomes unreachable code and nothing else in this suite would notice — // the action would still return it, and core would still retry. for (const a of events.ACTIONS) { assert.ok( a.budgetMs > sidecar.TIMEOUT_MS, `${a.id}: budgetMs ${a.budgetMs} must exceed the client's ${sidecar.TIMEOUT_MS}`, ) } }) test('TRAP 1: an unrecognised failure is a RETRY', () => { // The default direction. A module that listed the transient statuses and // defaulted the rest to terminal would stop retrying the moment its sidecar // grew a status nobody here had heard of. const verdict = events.classify({ ok: false, status: 'something-new' }) assert.strictEqual(verdict.ok, false) assert.strictEqual(verdict.retry, true) }) test('TRAP 1: a timeout is a retry and an unknown command is not', () => { assert.strictEqual(events.classify({ ok: false, status: 'timeout' }).retry, true) assert.strictEqual(events.classify({ ok: false, status: 'unknown-command' }).retry, false) }) test('a refusal says WHY, in the field core actually reads', async () => { // Core's dispatcher carries `error` off a failure envelope and nothing else. // A reason under any other name — `detail`, `message`, `reason` — is dropped in // silence and the operator sees " refused". This test exists because // the first draft of this template used `detail`, on the strength of the one // place `EVENTS.md` mentions it, and every refusal it produced was anonymous. const answer = await action.perform({ idempotencyKey: nextKey(), params: { clanId: 'clan-1', count: 0 }, }) assert.strictEqual(answer.ok, false) assert.strictEqual(typeof answer.error, 'string') assert.ok(answer.error.length > 0, 'a refusal with no `error` tells an author nothing') // And the same for a failure this module classified rather than authored. assert.strictEqual(typeof events.classify({ ok: false, status: 'timeout' }).error, 'string') }) test('TRAP 1: a refusal the second attempt would repeat says retry: false', async () => { // The arm the inequality above exists to keep reachable. A count core would // hand back identically on a retry is not worth a second round trip. const answer = await action.perform({ idempotencyKey: nextKey(), params: { clanId: 'clan-1', count: 9999 }, }) assert.strictEqual(answer.ok, false) assert.strictEqual(answer.retry, false) }) // ══ TRAP 2 — the idempotency passthrough ═════════════════════════════════ test("TRAP 2: perform passes core's key through, unchanged", async () => { const seen = [] const realSend = sidecar.send // Wrapping the module's own client rather than a fake one: what is under test // is that the key reaches the call, and a fake client would only prove the // test passed it to itself. sidecar.send = async (command, payload, options) => { seen.push(options && options.idempotencyKey) return realSend(command, payload, options) } try { const key = nextKey() await action.perform({ idempotencyKey: key, params: { clanId: 'clan-1', count: 2 } }) assert.deepStrictEqual(seen, [key], 'the key core gave us is not the key that went down the wire') } finally { sidecar.send = realSend } }) test('TRAP 2: a retry under the same key changes the world once', async () => { // The property the passthrough buys, stated as behaviour rather than as a // parameter. Two attempts, one key: the second collects the answer the first // already produced, and the refs are identical. const key = nextKey() const params = { clanId: 'clan-1', count: 3 } const first = await action.perform({ idempotencyKey: key, params }) const second = await action.perform({ idempotencyKey: key, params }) assert.strictEqual(first.ok, true) assert.strictEqual(second.ok, true) assert.deepStrictEqual( second.resources.map((r) => r.ref), first.resources.map((r) => r.ref), 'the repeat produced NEW refs — that is two sets of beacons and one ledger', ) }) test('TRAP 2: a fresh key on the same params is a second, real change', async () => { // The control for the test above. If this passed identically, the far end // would be deduplicating on the params rather than on the key, and the test // above would be proving nothing. const params = { clanId: 'clan-1', count: 3 } const first = await action.perform({ idempotencyKey: nextKey(), params }) const second = await action.perform({ idempotencyKey: nextKey(), params }) assert.notDeepStrictEqual( second.resources.map((r) => r.ref), first.resources.map((r) => r.ref), ) }) test('TRAP 2: a call with no key is refused rather than sent', async () => { const answer = await sidecar.send('beacon.light', { clanId: 'clan-1', count: 1 }, {}) assert.strictEqual(answer.ok, false) assert.strictEqual(answer.status, 'no-idempotency-key') }) // ══ TRAP 3 — core records a resource BEFORE it is confirmed ══════════════ test('TRAP 3: reverting something that was never made is a SUCCESS', async () => { const answer = await action.revert({ idempotencyKey: nextKey(), resources: [{ kind: 'beacon', ref: 'beacon:never-existed:1' }], }) // The message avoids the words `from "..."` on purpose: `checkImports.js` is // deliberately textual and reads that shape as an import specifier, prose or not. assert.strictEqual(answer.ok, true, 'removing something absent must be a success') }) test('TRAP 3: revert is idempotent — core may ask more than once', async () => { const made = await action.perform({ idempotencyKey: nextKey(), params: { clanId: 'clan-1', count: 2 }, }) const first = await action.revert({ idempotencyKey: nextKey(), resources: made.resources }) const again = await action.revert({ idempotencyKey: nextKey(), resources: made.resources }) assert.strictEqual(first.ok, true) assert.strictEqual(again.ok, true) }) test('TRAP 3: revert is called with NO resources and only a key', async () => { // The lost-answer case: core knows a dispatch went out under this key and never // learned what it made. This module CAN answer it. One that cannot must say // `{ ok: false }` and let a human see the row — never `{ ok: true }`, which is // how a resource burns forever with the ledger reporting it cleaned up. const answer = await action.revert({ idempotencyKey: nextKey(), resources: [] }) assert.strictEqual(answer.ok, true) }) test('TRAP 3: reconcile reports what is gone and never guesses', async () => { const made = await action.perform({ idempotencyKey: nextKey(), params: { clanId: 'clan-1', count: 2 }, }) const before = await action.reconcile({ resources: made.resources }) assert.strictEqual(before.ok, true) assert.deepStrictEqual(before.inForce.sort(), made.resources.map((r) => r.ref).sort()) sidecar.simulateRestart() const after = await action.reconcile({ resources: made.resources }) assert.strictEqual(after.ok, true) assert.deepStrictEqual(after.inForce, [], 'a restart lost them; reconcile must say so') }) // ══ TRAP 4 — the cost that is priced and never reconciled ════════════════ test('TRAP 4: cost counts what one invocation actually makes', async () => { // The failure this catches is `() => ({ 'examplegame.beacons': 1 })`, which // would pass every other test in this file and turn an operator's cap of 30 // into a cap of 750. Core prices `cost` before dispatch and NEVER reconciles it // against the resources that come back, so nothing else can catch it. const params = { clanId: 'clan-1', count: 7 } const priced = action.cost(params) const made = await action.perform({ idempotencyKey: nextKey(), params }) assert.strictEqual( priced['examplegame.beacons'], made.resources.length, 'the action declared a different number than it made — every cap on this dimension is a lie', ) }) test('TRAP 4: cost only names dimensions this module declared', () => { // A `cost()` naming an undeclared dimension is REFUSED at save, at the dry run // and at dispatch, because the fix is a module's declaration rather than a // deployment's cap. Cheaper to find here. const declared = new Set(events.BUDGETS.map((b) => b.id)) for (const a of events.ACTIONS) { const sample = Object.fromEntries(a.params.map((p) => [p.name, p.example])) for (const dimension of Object.keys(a.cost(sample))) { assert.ok(declared.has(dimension), `${a.id} spends ${dimension}, which no module here declares`) } } }) // ══ verify ════════════════════════════════════════════════════════════════ test('verify changes nothing', async () => { const before = await action.reconcile({ resources: [] }) const dry = await action.perform({ idempotencyKey: nextKey(), params: { clanId: 'clan-1', count: 5 }, verify: true, }) assert.strictEqual(dry.ok, true) assert.strictEqual(dry.resources, undefined, 'a dry run must not report resources it did not make') // Nothing was lit, so nothing new is in force. The assertion is weak on its own // and strong beside the TRAP 3 reconcile test above, which proves the same call // does see what `perform` makes. const after = await action.reconcile({ resources: [] }) assert.deepStrictEqual(after.inForce, before.inForce) }) test('verify answers honestly rather than always true', async () => { const dry = await action.perform({ idempotencyKey: nextKey(), params: { clanId: 'no-such-clan', count: 1 }, verify: true, }) assert.strictEqual(dry.ok, false) assert.strictEqual(dry.retry, false) }) // ══ The lease ═════════════════════════════════════════════════════════════ test('a lease reads a baseline, holds a value, and gives it back', async () => { sidecar.simulateRestart() const baseline = await lease.read() assert.strictEqual(baseline.ok, true) assert.strictEqual(baseline.value, 1.0) const until = new Date(Date.now() + 60_000) assert.strictEqual((await lease.apply(2.5, until)).ok, true) assert.strictEqual((await lease.read()).value, 2.5) const back = await lease.restore(baseline.value, { expected: 2.5 }) assert.strictEqual(back.ok, true) assert.strictEqual((await lease.read()).value, 1.0) }) test('a lease reports DRIFT rather than overwriting what somebody changed', async () => { sidecar.simulateRestart() const baseline = await lease.read() await lease.apply(3, new Date(Date.now() + 60_000)) // Somebody moved it by hand, mid-event. await lease.apply(4, new Date(Date.now() + 60_000)) const back = await lease.restore(baseline.value, { expected: 3 }) assert.strictEqual(back.ok, true) assert.strictEqual(back.drifted, true, 'restoring over a hand-edit silently is the bug') assert.strictEqual(Number(back.value), 4) }) test('inForce is a different question from read', async () => { sidecar.simulateRestart() // Nothing held: the live value is the default. assert.strictEqual((await lease.inForce()).held, false) await lease.apply(2, new Date(Date.now() + 60_000)) assert.strictEqual((await lease.inForce()).held, true) // A restart takes the hold with it, and `inForce` is the only thing that says // so — `read()` would answer 1.0, which is also what an un-held lease reads. sidecar.simulateRestart() assert.strictEqual((await lease.inForce()).held, false) }) test('the lease declares a duration bound core can enforce', () => { for (const l of events.LEASES) { assert.ok(l.maxDurationMs > 0, `${l.id} has no duration bound`) assert.strictEqual(typeof l.read, 'function') assert.strictEqual(typeof l.apply, 'function') assert.strictEqual(typeof l.restore, 'function') } }) // ══ The option source ═════════════════════════════════════════════════════ test('an option source answers from live data', async () => { const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans') const options = await source.resolve() assert.ok(Array.isArray(options)) assert.strictEqual(options.length, CLANS.length) for (const option of options) { assert.strictEqual(typeof option.value, 'string') assert.strictEqual(typeof option.label, 'string') } }) test('an option source that fails degrades rather than raising', async () => { // Core turns a refusal into a free-text field with a warning; it never blocks // the authoring form. A resolver that threw would be a screen this module's // outage takes away, for a field whose value the operator very often knows. const real = clanDb.listClans clanDb.listClans = async () => { throw new Error('database is down') } try { const source = events.OPTION_SOURCES.find((s) => s.id === 'examplegame.options.clans') assert.deepStrictEqual(await source.resolve(), []) } finally { clanDb.listClans = real } })