feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
308
server/test/eventAnnounce.test.js
Normal file
308
server/test/eventAnnounce.test.js
Normal file
@@ -0,0 +1,308 @@
|
||||
// ── The lifecycle announcements (EVENTS_PLAN.md Phase 10) ──────────────────
|
||||
//
|
||||
// §J's "Events owns none of the delivery", made testable. What this file
|
||||
// asserts is that `events/announce.js` says the right thing and then stops —
|
||||
// nothing here knows about email, subscribers, templates or cooldowns, and the
|
||||
// evidence for that is that every test below stubs `engagementEmit.emit` and
|
||||
// reads what was handed to it.
|
||||
//
|
||||
// Three properties, and each is a thing that would be silent if it broke:
|
||||
//
|
||||
// • **a rehearsal narrows the ceiling** (§I). The same triggers fire, so the
|
||||
// announce steps are genuinely rehearsed — and `ceiling: 'staff'` on the
|
||||
// envelope is what stops a rehearsal of a published event mailing every
|
||||
// subscriber it. This is the one property in the phase with a blast radius.
|
||||
// • **the cooldown subject is the RUN.** Keyed on the user, `phase.changed`
|
||||
// would mean "at most one phase of at most one event an hour" and would
|
||||
// silently swallow the second wave of an invasion.
|
||||
// • **a read that fails does not fail the run.** Every caller is a transition
|
||||
// in the runner, and a run must not fail to start because the row that says
|
||||
// what it is called could not be read.
|
||||
//
|
||||
// The trigger declarations get their own assertions here rather than in
|
||||
// `engagementTriggers.test.js`, because what is being checked is not that the
|
||||
// registry accepts them — it does, for anything well-formed — but the two
|
||||
// EVENTS.md decisions they encode: which ceiling each sits at, and that the six
|
||||
// public ones declare no `url` variable while there is no public page to point
|
||||
// at (the `news.post` mistake, not repeated).
|
||||
|
||||
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 announce = require('../src/events/announce')
|
||||
const engagementEmit = require('../src/utils/engagementEmit')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||
const logDb = require('../src/model/events/eventRunLog.db')
|
||||
const { TRIGGERS } = require('../src/config/coreTriggers')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const DEFINITION = {
|
||||
id: 3,
|
||||
title: 'The Yew Invasion',
|
||||
summary: 'Orcish warbands are massing north of Yew.',
|
||||
series_name: 'The Yew Campaign',
|
||||
timezone: 'America/New_York',
|
||||
}
|
||||
|
||||
const RUN = {
|
||||
id: 3692,
|
||||
definition_id: 3,
|
||||
timezone: 'America/New_York',
|
||||
scheduled_for: new Date('2026-09-13T00:00:00.000Z'),
|
||||
started_at: new Date('2026-09-13T00:00:14.000Z'),
|
||||
current_phase: 'assault',
|
||||
rehearsal: 0,
|
||||
last_error: null,
|
||||
}
|
||||
|
||||
let emitted
|
||||
let lines
|
||||
const originals = {
|
||||
emit: engagementEmit.emit,
|
||||
getById: definitionsDb.getById,
|
||||
count: participantsDb.countForRun,
|
||||
write: logDb.write,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
emitted = []
|
||||
lines = []
|
||||
engagementEmit.emit = (owner, triggerId, envelope) => {
|
||||
emitted.push({ owner, triggerId, envelope })
|
||||
return { ok: true }
|
||||
}
|
||||
definitionsDb.getById = async (id) => (id === DEFINITION.id ? { ...DEFINITION } : null)
|
||||
participantsDb.countForRun = async () => 12
|
||||
logDb.write = async (line) => {
|
||||
lines.push(line)
|
||||
}
|
||||
})
|
||||
|
||||
after(() => {
|
||||
engagementEmit.emit = originals.emit
|
||||
definitionsDb.getById = originals.getById
|
||||
participantsDb.countForRun = originals.count
|
||||
logDb.write = originals.write
|
||||
})
|
||||
|
||||
const only = () => {
|
||||
assert.equal(emitted.length, 1, `expected one emit, got ${emitted.length}`)
|
||||
return emitted[0]
|
||||
}
|
||||
|
||||
// ── The shared envelope ────────────────────────────────────────────────────
|
||||
|
||||
test('core emits as core, keyed on the run, with the definition\'s own facts', async () => {
|
||||
await announce.runStarted(RUN)
|
||||
const { owner, triggerId, envelope } = only()
|
||||
|
||||
assert.equal(owner, 'core')
|
||||
assert.equal(triggerId, 'event.run.started')
|
||||
// Keyed on the RUN. Two occurrences of a weekly event are two subjects, so
|
||||
// last week's mail does not throttle this week's.
|
||||
assert.equal(envelope.subject, '3692')
|
||||
assert.equal(envelope.scopeKey, 'event:3692')
|
||||
assert.equal(envelope.data.runId, '3692')
|
||||
assert.equal(envelope.data.title, 'The Yew Invasion')
|
||||
assert.equal(envelope.data.summary, DEFINITION.summary)
|
||||
assert.equal(envelope.data.seriesName, 'The Yew Campaign')
|
||||
})
|
||||
|
||||
test('a real run carries NO ceiling, so the declaration\'s own is what bounds it', async () => {
|
||||
await announce.runStarted(RUN)
|
||||
assert.equal(only().envelope.ceiling, undefined)
|
||||
})
|
||||
|
||||
test('a REHEARSAL fires the same trigger and ceilings it at staff', async () => {
|
||||
// §I: "run for real with announcements ceilinged to `staff`". Emitting
|
||||
// nothing would be a rehearsal of everything except the announcements.
|
||||
await announce.runStarted({ ...RUN, rehearsal: 1 })
|
||||
const { triggerId, envelope } = only()
|
||||
assert.equal(triggerId, 'event.run.started')
|
||||
assert.equal(envelope.ceiling, 'staff')
|
||||
})
|
||||
|
||||
test('the run log says what was announced and, on a rehearsal, why it was bounded', async () => {
|
||||
await announce.runStarted({ ...RUN, rehearsal: 1 })
|
||||
const line = lines.find((l) => l.kind === 'announcement.emitted')
|
||||
assert.equal(line.detail.trigger, 'event.run.started')
|
||||
assert.equal(line.detail.ceiling, 'staff')
|
||||
assert.equal(line.detail.because, 'rehearsal')
|
||||
// How many people were told is the engagement engine's decision and its own
|
||||
// log line. A run log that reported a number would be claiming a decision it
|
||||
// does not make.
|
||||
assert.equal(line.detail.recipients, undefined)
|
||||
})
|
||||
|
||||
test('a definition that has gone away announces nothing and does not throw', async () => {
|
||||
await announce.runStarted({ ...RUN, definition_id: 999 })
|
||||
assert.equal(emitted.length, 0)
|
||||
assert.equal(lines.length, 0)
|
||||
})
|
||||
|
||||
test('a read that throws is swallowed — a run must not fail to start over an announcement', async () => {
|
||||
definitionsDb.getById = async () => {
|
||||
throw new Error('pool timeout')
|
||||
}
|
||||
await announce.runStarted(RUN)
|
||||
assert.equal(emitted.length, 0)
|
||||
})
|
||||
|
||||
// ── Per-moment payloads ────────────────────────────────────────────────────
|
||||
|
||||
test('phase.changed counts phases from one, for a human reading a sentence', async () => {
|
||||
await announce.phaseChanged(RUN, { phase: 'assault', label: 'The assault', index: 1, count: 4 })
|
||||
const { data } = only().envelope
|
||||
assert.equal(data.phase, 'assault')
|
||||
assert.equal(data.phaseLabel, 'The assault')
|
||||
assert.equal(data.phaseIndex, 2)
|
||||
assert.equal(data.phaseCount, 4)
|
||||
})
|
||||
|
||||
test('phase.changed falls back to the key when a phase has no label', async () => {
|
||||
await announce.phaseChanged(RUN, { phase: 'assault', label: null, index: 0, count: 2 })
|
||||
assert.equal(only().envelope.data.phaseLabel, 'assault')
|
||||
})
|
||||
|
||||
test('run.completed counts the participants and the minutes it took', async () => {
|
||||
await announce.runCompleted(RUN, new Date('2026-09-13T01:35:14.000Z'))
|
||||
const { data } = only().envelope
|
||||
assert.equal(data.participantCount, 12)
|
||||
assert.equal(data.durationMinutes, 95)
|
||||
})
|
||||
|
||||
test('a participant count that cannot be read is zero, not missing', async () => {
|
||||
// The variable is declared `required`, so it has to be a number — and zero is
|
||||
// also the honest answer for the far more common case of a run nothing
|
||||
// collected for.
|
||||
participantsDb.countForRun = async () => {
|
||||
throw new Error('table is gone')
|
||||
}
|
||||
await announce.runCompleted(RUN, new Date('2026-09-13T00:30:14.000Z'))
|
||||
assert.equal(only().envelope.data.participantCount, 0)
|
||||
})
|
||||
|
||||
test('a run that never started reports no duration rather than a negative one', async () => {
|
||||
await announce.runCompleted({ ...RUN, started_at: null }, new Date('2026-09-13T01:00:00.000Z'))
|
||||
assert.equal(only().envelope.data.durationMinutes, 0)
|
||||
})
|
||||
|
||||
test('run.cancelled carries the operator\'s reason, and omits it when none was given', async () => {
|
||||
await announce.runCancelled(RUN, 'The shard is down for an emergency patch.')
|
||||
assert.equal(only().envelope.data.reason, 'The shard is down for an emergency patch.')
|
||||
emitted = []
|
||||
await announce.runCancelled(RUN, null)
|
||||
assert.equal(only().envelope.data.reason, undefined)
|
||||
})
|
||||
|
||||
test('run.failed links the run console — the one destination that exists today', async () => {
|
||||
await announce.runFailed(RUN, 'sidecar responded 503')
|
||||
const { data } = only().envelope
|
||||
assert.equal(data.error, 'sidecar responded 503')
|
||||
assert.equal(data.phase, 'assault')
|
||||
assert.equal(data.runUrl, '/admin/events/runs/3692')
|
||||
})
|
||||
|
||||
test('run.failed falls back to the run\'s own last error', async () => {
|
||||
await announce.runFailed({ ...RUN, last_error: 'the pinned version has no phases' }, null)
|
||||
assert.equal(only().envelope.data.error, 'the pinned version has no phases')
|
||||
})
|
||||
|
||||
// ── startsAtLabel: the presentational fragment ─────────────────────────────
|
||||
|
||||
test('the start time is written out in the SHARD\'s zone, not the server\'s', () => {
|
||||
// Midnight UTC on the 13th is 8pm on the 12th in New York, and the whole point
|
||||
// of the label is that a reader sees the shard's evening.
|
||||
const label = announce.startsAtLabel(new Date('2026-09-13T00:00:00Z'), 'America/New_York')
|
||||
assert.match(label, /^Saturday 12 September at 8:00 pm \(America\/New_York\)$/)
|
||||
})
|
||||
|
||||
test('midnight reads as 12:00 am and never as 00:00', () => {
|
||||
// `hour12` is set explicitly. Left to the en-GB locale this would render
|
||||
// "00:00" while the schedule editor beside it writes "12:00 AM" — one event,
|
||||
// two spellings of the same instant.
|
||||
assert.match(announce.startsAtLabel(new Date('2026-09-13T04:00:00Z'), 'America/New_York'), /12:00 am/)
|
||||
})
|
||||
|
||||
test('a bad zone or a bad instant answers nothing rather than throwing', () => {
|
||||
// The variable is optional and its template block is a single token, so an
|
||||
// absent label renders as nothing at all rather than as a broken line.
|
||||
assert.equal(announce.startsAtLabel(new Date('2026-09-13T00:00:00Z'), 'Middle/Earth'), undefined)
|
||||
assert.equal(announce.startsAtLabel('not a date', 'UTC'), undefined)
|
||||
})
|
||||
|
||||
test('the label rides on the two triggers that have a start time', async () => {
|
||||
await announce.runScheduled(RUN)
|
||||
assert.match(only().envelope.data.startsAtLabel, /8:00 pm \(America\/New_York\)/)
|
||||
emitted = []
|
||||
await announce.runEnding(RUN)
|
||||
assert.equal(only().envelope.data.startsAtLabel, undefined)
|
||||
})
|
||||
|
||||
// ── The declarations themselves ────────────────────────────────────────────
|
||||
|
||||
const eventTriggers = () => TRIGGERS.filter((t) => t.id.startsWith('event.'))
|
||||
|
||||
test('seven event triggers, and every one is keyed on the run', () => {
|
||||
const ids = eventTriggers().map((t) => t.id)
|
||||
assert.deepEqual(ids, [
|
||||
'event.run.scheduled',
|
||||
'event.run.started',
|
||||
'event.phase.changed',
|
||||
'event.run.ending',
|
||||
'event.run.completed',
|
||||
'event.run.cancelled',
|
||||
'event.run.failed',
|
||||
])
|
||||
for (const t of eventTriggers()) {
|
||||
assert.equal(t.subjectKey, 'runId', `${t.id} must key its cooldown on the run`)
|
||||
assert.ok(
|
||||
t.variables.some((v) => v.name === 'runId' && v.required),
|
||||
`${t.id} declares subjectKey runId, so runId must be a required variable`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('six are ceilinged authenticated; run.failed is admin on both halves', () => {
|
||||
for (const t of eventTriggers()) {
|
||||
if (t.id === 'event.run.failed') {
|
||||
// A failure names the deployment's own broken machinery. There is no
|
||||
// widening of this that is not a disclosure, so the CEILING says so.
|
||||
assert.equal(t.ceiling, 'admin')
|
||||
assert.equal(t.audience, 'admin')
|
||||
} else {
|
||||
assert.equal(t.ceiling, 'authenticated', `${t.id}`)
|
||||
assert.equal(t.audience, 'subscribers', `${t.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('no public event trigger declares a url — there is no page for one to point at yet', () => {
|
||||
// `news.post` shipped an example naming `/news/<slug>`, a path that does not
|
||||
// exist, and the template editor previewed a link that was dead in every mail
|
||||
// it sent. Phase 14 adds the variable alongside the page.
|
||||
for (const t of eventTriggers()) {
|
||||
const urls = t.variables.filter((v) => v.type === 'url')
|
||||
if (t.id === 'event.run.failed') {
|
||||
assert.deepEqual(urls.map((v) => v.name), ['runUrl'])
|
||||
assert.match(urls[0].example, /^\/admin\/events\/runs\//)
|
||||
} else {
|
||||
assert.deepEqual(urls, [], `${t.id} must declare no url until Phase 14`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every declared variable carries an example, which is what the editor previews with', () => {
|
||||
for (const t of eventTriggers()) {
|
||||
for (const v of t.variables) {
|
||||
assert.notEqual(v.example, undefined, `${t.id}.${v.name} needs an example`)
|
||||
assert.ok(v.description, `${t.id}.${v.name} needs a description`)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user