// module-uo's event verbs, wave 1 (EVENTS_PLAN.md Phase 9). // // The declarations are data plus three `perform()`s, so most of this suite is // about the *shapes* core will check and the failure paths a live rig cannot be // made to produce on demand — a sidecar that answers 409, a shard that restarts // between two steps, a crier line one character over the cap. // // **The first test is the one the whole phase rests on.** Every other property // here — "a broadcast is sent once", "a failed post is retried" — is a claim // about what the MODULE decided, and the module only gets to decide when its // client answers before core's dispatch deadline. Assert the relationship, not // the numbers, or the day someone tunes one of them the suite stays green while // the behaviour inverts. const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const uoLinkClient = require('../utils/uoLinkClient') const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') const shardAtlas = require('../model/shardAtlas/shardAtlas.model') require('./_setup') const actions = require('../config/uoEventActions') const byId = (id) => actions.ACTIONS.find((a) => a.id === id) let calls const saved = {} beforeEach(() => { calls = { broadcast: [], crier: [], crierDel: [], news: [], newsDel: [] } for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) { saved[name] = uoLinkClient[name] } saved.getSafe = uoLinkConfig.getSafe saved.listRegions = shardAtlas.listRegions saved.listLandmarks = shardAtlas.listLandmarks saved.searchCreatures = shardAtlas.searchCreatures uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } } uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } } uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } } uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } } uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } } uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' }) }) afterEach(() => { for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) { uoLinkClient[name] = saved[name] } uoLinkConfig.getSafe = saved.getSafe shardAtlas.listRegions = saved.listRegions shardAtlas.listLandmarks = saved.listLandmarks shardAtlas.searchCreatures = saved.searchCreatures }) // ── The rule everything else depends on ──────────────────────────────────── test('every action outlives the sidecar client, so the module classifies its own failures', () => { // `dispatch.classify()` answers `retry` for a budget timeout unconditionally // and never asks the action. If core's deadline can fire before the client // gives up, `retry: false` below is unreachable and a broadcast is retried. for (const action of actions.ACTIONS) { assert.ok( action.budgetMs > uoLinkClient.TIMEOUT_MS, `${action.id} budgetMs (${action.budgetMs}) must exceed uoLinkClient.TIMEOUT_MS (${uoLinkClient.TIMEOUT_MS})`, ) } }) // ── The declarations, against the checks core will run ───────────────────── test('the declarations satisfy the shape core validates them with', () => { const RISKS = ['notify', 'inspect', 'change', 'irreversible'] const REVERSIBLE = ['none', 'self', 'ledger', 'override'] const PARAM_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url'] for (const a of actions.ACTIONS) { assert.ok(a.id.startsWith('uo.'), `${a.id} must be namespaced to this module`) assert.ok(a.label && a.description, `${a.id} needs a label and a description`) assert.ok(RISKS.includes(a.risk), `${a.id} has an unknown risk class`) assert.ok(REVERSIBLE.includes(a.reversible), `${a.id} has an unknown reversible class`) assert.equal(typeof a.perform, 'function') // `revert` is required iff ledger, and forbidden otherwise — a revert on a // non-ledgering action is an undo core will never call. assert.equal( typeof a.revert === 'function', a.reversible === 'ledger', `${a.id} revert() must be present exactly when reversible is 'ledger'`, ) // `reconcile` is optional, but only meaningful where something is ledgered. if (a.reconcile !== undefined) { assert.equal(typeof a.reconcile, 'function') assert.ok(a.reversible === 'ledger' || a.reversible === 'override', `${a.id} reconciles but ledgers nothing`) } if (a.cost !== undefined) assert.equal(typeof a.cost, 'function') const names = new Set() for (const p of a.params) { assert.ok(!names.has(p.name), `${a.id} declares ${p.name} twice`) names.add(p.name) assert.ok(PARAM_TYPES.includes(p.type), `${a.id}.${p.name} has an unsupported type "${p.type}"`) // Required on every param including the optional ones: it is the authoring // placeholder, and an unattended world write typed into a blank box is how // a typo gets scheduled. assert.ok( p.example !== undefined && p.example !== null && p.example !== '', `${a.id}.${p.name} needs an example`, ) assert.ok(p.description, `${a.id}.${p.name} needs a description`) } } }) test('a broadcast spends the one budget dimension the module declares', () => { const declared = new Set(actions.BUDGETS.map((b) => b.id)) assert.deepEqual([...declared], ['uo.broadcasts']) for (const b of actions.BUDGETS) { assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced') assert.ok(b.label && b.unit, 'a dimension is rendered as a label and a unit beside a number') } // Every dimension a cost names must be one the module declared, or core is // asked to bound something nothing defines. const cost = byId('uo.broadcast').cost({}) assert.deepEqual(cost, { 'uo.broadcasts': 1 }) for (const id of Object.keys(cost)) assert.ok(declared.has(id), `${id} is spent but never declared`) // The keyed verbs deliberately spend nothing: a repeat REPLACES under the same // id, so there is no runaway for a cap to bound. assert.equal(byId('uo.towncrier.post').cost, undefined) assert.equal(byId('uo.news.post').cost, undefined) }) // ── uo.broadcast: retried, because protocol 6 made that safe ─────────────── test('a broadcast is retried on a transient failure and never on a permanent one', async () => { const broadcast = byId('uo.broadcast') // Wave 1 asserted the opposite of this — every failure terminal, including the // two that are plainly transient — because nothing on the wire could stop a // retry announcing to everyone twice. Protocol 6 puts an idempotency key on the // command and the shard refuses the repeat, so the trade that test recorded is // no longer one that has to be made. // // 425 is the new status in this list: `bridge.busy`, the shard saying a command // under this key is still in flight. Transient by construction. const TRANSIENT = new Set([0, 425, 503, 504]) for (const status of [0, 400, 401, 403, 409, 425, 503, 504]) { uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` }) const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false }) assert.equal(result.ok, false) assert.equal(result.retry, TRANSIENT.has(status), `a ${status} retries iff it is transient`) } }) test('every write carries the step idempotency key, unchanged', async () => { // The key is what makes the retry above safe, so a verb that dropped it would // silently restore the wave-1 hazard while every other assertion still passed. // Asserted per verb rather than once, because each builds its own body. const KEY = 'a'.repeat(40) const seen = {} uoLinkClient.adminBroadcast = async (body) => { seen.broadcast = body; return { ok: true } } uoLinkClient.postTownCrier = async (body) => { seen.crier = body; return { ok: true } } uoLinkClient.postNews = async (body) => { seen.news = body; return { ok: true } } await byId('uo.broadcast').perform({ runId: 7, idempotencyKey: KEY, params: { text: 'hear ye' }, verify: false, }) await byId('uo.towncrier.post').perform({ runId: 7, idempotencyKey: KEY, params: { lines: 'hear ye' }, verify: false, }) await byId('uo.news.post').perform({ runId: 7, idempotencyKey: KEY, params: { title: 'A thing', body: 'happened' }, verify: false, }) assert.equal(seen.broadcast.idempotencyKey, KEY) assert.equal(seen.crier.idempotencyKey, KEY) assert.equal(seen.news.idempotencyKey, KEY) // The two keyed verbs post under an id DERIVED from the key. Both travel: the // id is what makes a repeat replace, the key is what stops it re-announcing. assert.equal(seen.crier.id, `evt-${KEY}`) assert.equal(seen.news.id, `evt-${KEY}`) }) test("the shard's own words reach the run log, not just a status code", async () => { // **The rig found this.** The sidecar refuses a broadcast with // `{"reason":"admin write plane disabled"}` and `legError` looks for // `data.message`, so the run console read "sidecar responded 403" for a cause // the shard had already explained in a sentence. A staff member clicking a // button knows what they switched off; an event that ran at four in the morning // leaves the run log as the only place anyone will learn why. uoLinkClient.adminBroadcast = async () => ({ ok: false, status: 403, data: { kind: 'admin.error', reason: 'admin write plane disabled' }, error: 'sidecar responded 403', }) const result = await byId('uo.broadcast').perform({ runId: 1, params: { text: 'hear ye' }, verify: false }) assert.match(result.error, /admin write plane disabled/) // And NOT the double-announce clause: a 403 will not succeed on any attempt, so // pointing an operator at a policy decision misdirects them away from the // switch they actually have to flip. assert.doesNotMatch(result.error, /announce twice/) assert.equal(result.retry, false) }) test('a permanent refusal of a keyed verb is not retried either', async () => { // Same distinction on the other side: the keyed verbs DO retry a transient, and // must not burn three attempts on a refusal that cannot change. uoLinkClient.postTownCrier = async () => ({ ok: false, status: 403, data: { reason: 'admin write plane disabled' } }) const result = await byId('uo.towncrier.post').perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines: 'hear ye' }, verify: false, }) assert.equal(result.retry, false) assert.match(result.error, /admin write plane disabled/) }) test('a broadcast names its run in the shard audit, not a staff member', async () => { await byId('uo.broadcast').perform({ runId: 42, params: { text: 'hear ye', hue: 1153 }, verify: false }) assert.equal(calls.broadcast.length, 1) assert.equal(calls.broadcast[0].actor, 'event:42') assert.equal(calls.broadcast[0].hue, 1153) }) test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => { const broadcast = byId('uo.broadcast') const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1) const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true }) assert.equal(dry.ok, false) assert.equal(dry.retry, false) assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN))) const live = await broadcast.perform({ runId: 1, params: { text }, verify: false }) assert.equal(live.ok, false) assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached') }) test('a dry run sends nothing at all', async () => { for (const action of actions.ACTIONS) { const params = {} for (const p of action.params) if (p.required) params[p.name] = p.example const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true }) assert.equal(result.ok, true, `${action.id} refused its own example params`) assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`) } assert.deepEqual( [calls.broadcast.length, calls.crier.length, calls.news.length], [0, 0, 0], 'a dry run reached the shard', ) }) // ── The keyed verbs: one id, stable across a retry ───────────────────────── test('the crier and the news gump post under a run-stable id a retry replaces', async () => { const key = 'a1b2c3'.padEnd(40, '0') await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false }) await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false }) assert.equal(calls.crier.length, 2) assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack') assert.equal(calls.crier[0].id, `evt-${key}`) // The sidecar's own cap on the id column. assert.ok(calls.crier[0].id.length <= 64) }) test('an event article cannot collide with a website post in the news gump', async () => { // `newsGump.js` posts site articles under the bare post id and re-pushes that // whole set on every reconnect. An event article numbered into the same space // would silently be a collision with a post, in whichever direction wrote last. await byId('uo.news.post').perform({ runId: 9, idempotencyKey: 'f'.repeat(40), params: { title: 'The Fair', body: 'Merchants gather.' }, verify: false, }) assert.equal(calls.news.length, 1) assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post') assert.match(calls.news[0].id, /^evt-/) assert.match(calls.news[0].body, /