Files
Integration-kit/template/server/test/eventActions.test.js
wtclaude f89044b42e
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s
feat(kit): the event contract, taught and built (chapter 5)
The fifth chapter, and the template code it teaches out of. Events is the first
thing in the book that goes the other way — chapters 1-4 move data out of the
game and onto a page; an event changes a live world on a schedule, unattended.

**Chapter 5** covers the four declarations (budgets, option sources, leases,
actions), leads with the lease because EVENTS.md §H is right that it is the
primitive that travels and the spawn is the special case, and gives one section
each to the four things that are invisible until an outage: the envelope's
failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring `cost`.

**Chapters 3 and 4 gain one section each** for the command plane, because
without them chapter 5 teaches a module to send an idempotency key to a sidecar
the book never told anyone to build a command path in. Both say at the top that
they are skippable until you want chapter 5.

**The template ships one of each declaration**, with `server/sidecarClient.js`
as the near end — a real timeout, a real key passthrough, a simulated transport
in one function marked for replacement. That file is named for the filename
`noGameConnection.test.js` already anticipated, so the test stays green now and
fires correctly the moment `deliver()` becomes a request.

Two things writing it found, both now in the chapter and beside the code:

  * **An idempotency key belongs on a command, never on a question.** The first
    draft keyed every call including the reads; an at-most-once store then
    answers every future read with the first one's reply, forever. The lease
    applied correctly and the module could no longer see it. Hence `ask` and
    `send` as two functions.

  * **A refusal's reason goes in `error`; core reads no other name.** The first
    draft used `detail`, on the strength of the one place EVENTS.md §H mentions
    it, and every refusal it produced was anonymous on the run console.

Proved by running the template's real declarations through core's real registry
at `edge` (all four accepted) and its real envelopes through the real
`events/dispatch.js` classifier.

**CI is RED on `checkCoreApi` and that is the mechanism working.** The template
now declares `coreApi: ^1.10.0` and `ci/core-ref.json` pins the engagement
cutover, where `main` is still 1.9.0. Equality is the check, a bump is meant to
turn this repo red until someone re-reads the chapters, and the pin move rides
in the events cutover (EVENTS_PLAN.md P16) as its own commit. Do not "fix" it.

Refs EVENTS_PLAN.md Phase 15, EVENTS.md §F, MODULE_API.md 1.10.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:10:18 -05:00

400 lines
17 KiB
JavaScript

// ── 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 "<action id> 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
}
})