// ── Who took part (EVENTS_PLAN.md Phase 10) ──────────────────────────────── // // The write half of `event_run_participants`, and it is `events/ledger.js`'s // twin in every respect that matters — same envelope, same two success shapes, // same fail-open-on-a-bad-entry posture. The properties worth a test are the // ones where getting it wrong is silent: // // • a module's bad entry is DROPPED, never a retry — a retried step is a // re-dispatched world write, which is a far worse outcome than one missing // name on a leaderboard // • a `userId` is checked rather than coerced, because the column is a foreign // key into `users` and a character serial that happened to be a real user id // would attribute somebody's attendance to a stranger // • one step may not report the same member twice, and the duplicate is NAMED // rather than silently letting the last one win // • the classifier carries `participants` on both success shapes, including // `await: 'human'` — a cue's confirm finishes the step without a second // dispatch, so that is the only moment its participants can be recorded process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const participants = require('../src/events/participants') const participantsDb = require('../src/model/events/eventRunParticipants.db') const { classify } = require('../src/events/dispatch') const db = require('../src/utils/db') after(() => db.close()) const ACTION = { id: 'uo.participants.collect' } const RUN = { id: 42 } const STEP = { id: 7, phase: 'main' } let written const original = participantsDb.record beforeEach(() => { written = [] participantsDb.record = async (row) => { written.push(row) } }) after(() => { participantsDb.record = original }) // ── normalise ────────────────────────────────────────────────────────────── test('a member key is required and is the only required field', () => { const ok = participants.normalise({ memberKey: 'darrow' }, ACTION.id) assert.equal(ok.ok, true) assert.equal(ok.row.memberKey, 'darrow') assert.equal(ok.row.userId, null) assert.equal(ok.row.score, 0) assert.equal(ok.row.meta, null) assert.equal(ok.row.joinedAt, null) }) test('every bad shape is refused, and each names what was wrong', () => { const bad = [ [null, /not an object/], ['darrow', /not an object/], [[], /not an object/], [{}, /bad memberKey/], [{ memberKey: '' }, /bad memberKey/], [{ memberKey: 'x'.repeat(participants.MAX_MEMBER_KEY + 1) }, /bad memberKey/], [{ memberKey: 'd', userId: 0 }, /bad userId/], [{ memberKey: 'd', userId: -3 }, /bad userId/], [{ memberKey: 'd', userId: 1.5 }, /bad userId/], [{ memberKey: 'd', userId: '4' }, /bad userId/], [{ memberKey: 'd', score: 'lots' }, /bad score/], [{ memberKey: 'd', score: Number.NaN }, /bad score/], [{ memberKey: 'd', score: Infinity }, /bad score/], [{ memberKey: 'd', joinedAt: 'yesterday' }, /bad joinedAt/], ] for (const [entry, pattern] of bad) { const parsed = participants.normalise(entry, ACTION.id) assert.equal(parsed.ok, false, `${JSON.stringify(entry)} should be refused`) assert.match(parsed.reason, pattern) } }) test('a userId that is a real integer rides through; a serial-shaped string does not', () => { assert.equal(participants.normalise({ memberKey: 'd', userId: 12 }, ACTION.id).row.userId, 12) assert.equal(participants.normalise({ memberKey: 'd', userId: '0x4001' }, ACTION.id).ok, false) }) test('a negative score is legal — a game may score downward', () => { assert.equal(participants.normalise({ memberKey: 'd', score: -40 }, ACTION.id).row.score, -40) }) test('meta that is not an object is dropped rather than refusing the whole participant', () => { // Decoration on a row whose identity is already valid. Losing an event's // attendance over a stray string would be the wrong trade. const parsed = participants.normalise({ memberKey: 'd', meta: 'warrior' }, ACTION.id) assert.equal(parsed.ok, true) assert.equal(parsed.row.meta, null) assert.deepEqual(participants.normalise({ memberKey: 'd', meta: { c: 'mage' } }, ACTION.id).row.meta, { c: 'mage' }) }) // ── recordAnswer ─────────────────────────────────────────────────────────── test('nothing reported is not an error and writes nothing', async () => { assert.deepEqual(await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: undefined }), { recorded: 0, rejected: [], }) assert.equal(written.length, 0) }) test('a bad entry never fails the step, and the good ones beside it still land', async () => { const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: [{ memberKey: 'darrow', score: 12 }, { userId: 4 }, { memberKey: 'marisol' }], }) assert.equal(out.recorded, 2) assert.equal(out.rejected.length, 1) assert.match(out.rejected[0], /bad memberKey/) assert.deepEqual(written.map((w) => w.memberKey), ['darrow', 'marisol']) }) test('one step reporting the same member twice writes one row and names the duplicate', async () => { const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: [{ memberKey: 'darrow', score: 12 }, { memberKey: 'darrow', score: 99 }], }) assert.equal(out.recorded, 1) assert.match(out.rejected[0], /twice in one step/) assert.equal(written.length, 1) assert.equal(written[0].score, 12) }) test('more participants than one step may report is refused WHOLE, not truncated', async () => { // Half a leaderboard silently cut is worse than none: the table would look // complete and be wrong, and nothing downstream could tell. const many = Array.from({ length: participants.MAX_PER_STEP + 1 }, (_, i) => ({ memberKey: `m${i}` })) const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: many }) assert.equal(out.recorded, 0) assert.equal(written.length, 0) assert.match(out.rejected[0], /more than the/) }) test('a write that throws is recorded as a rejection rather than becoming the step\'s control flow', async () => { participantsDb.record = async () => { throw new Error('deadlock found when trying to get lock') } const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: [{ memberKey: 'darrow' }], }) assert.equal(out.recorded, 0) assert.match(out.rejected[0], /deadlock/) }) test('the run id is bound by the caller and never taken from the entry', async () => { await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, // A module cannot record somebody into another run by saying so. participants: [{ memberKey: 'darrow', runId: 99999 }], }) assert.equal(written[0].runId, RUN.id) }) // ── the classifier carries them ──────────────────────────────────────────── test('participants ride back on BOTH success shapes, and default to an empty list', () => { assert.deepEqual(classify({ ok: true, participants: [{ memberKey: 'd' }] }, 'a').participants, [{ memberKey: 'd' }]) assert.deepEqual( classify({ ok: true, await: 'human', participants: [{ memberKey: 'd' }] }, 'a').participants, [{ memberKey: 'd' }], ) assert.deepEqual(classify({ ok: true }, 'a').participants, []) assert.deepEqual(classify({ ok: true, await: 'human' }, 'a').participants, []) }) test('a FAILED envelope carries no participants at all', () => { // A step that did not succeed did not observe anybody, and an action that // reported attendance alongside a refusal is reporting something it cannot // know. There is no `participants` on a failure classification to read. assert.equal(classify({ ok: false, participants: [{ memberKey: 'd' }] }, 'a').participants, undefined) })