// ── 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', slug: 'the-yew-invasion', summary: 'Orcish warbands are massing north of Yew.', series_name: 'The Yew Campaign', timezone: 'America/New_York', // Both are load-bearing for `eventUrl` (Phase 14a): an event with no public // page gets no link. They were absent from this fixture, which meant the url // was undefined in every test here and the new code was exercised by none of // them. state: 'ready', listed: true, } 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) }) // `run.failed` alone gets no public page, and the DECLARATION is what enforces // that rather than anything here: `baseFor` assembles `eventUrl` for every // trigger and the seam drops the keys a trigger does not declare. The test above // that asserts run.failed's url variables are exactly `['runUrl']` is therefore // the one that proves it — an assertion on this envelope would be reading the // wrong layer, because the filtering has not happened yet at this point. 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('every public emit carries the page for THIS occurrence', async () => { await announce.runStarted(RUN) // The slug is the definition's and the run is in the query string. Without // `?run=` a mail about last Friday's occurrence would open next Friday's. assert.equal(only().envelope.data.eventUrl, '/site/events/the-yew-invasion?run=3692') }) test('an UNLISTED event announces with no link rather than a link that 404s', async () => { // `eventUrl` is declared optional exactly so `email.button` can drop itself. // A path here would render as a dead button in every mail — worse than none, // because it advertises a link the reader cannot follow. `news.post` paid for // that once already. definitionsDb.getById = async () => ({ ...DEFINITION, listed: false }) await announce.runStarted(RUN) assert.equal(only().envelope.data.eventUrl, undefined) }) test('a definition that is not yet `ready` has no page either', async () => { definitionsDb.getById = async () => ({ ...DEFINITION, state: 'draft' }) await announce.runStarted(RUN) assert.equal(only().envelope.data.eventUrl, undefined) }) 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('every public event trigger points at the page Phase 14a built, and run.failed at the console', () => { // The inverse of what this asserted from Phase 10 until Phase 14a, and the // inversion is the point: `news.post` shipped an example naming `/news/`, // a path that did not exist, so the template editor previewed a link that was // dead in every mail it sent. The variable was withheld until there was a page, // and it arrived with it. for (const t of eventTriggers()) { const urls = t.variables.filter((v) => v.type === 'url') if (t.id === 'event.run.failed') { // No `eventUrl` here, deliberately: an admin reading that the machinery // broke wants the steps and the errors, not the storyline. assert.deepEqual(urls.map((v) => v.name), ['runUrl']) assert.match(urls[0].example, /^\/admin\/events\/runs\//) } else { assert.deepEqual(urls.map((v) => v.name), ['eventUrl'], `${t.id}`) // The example has to carry `?run=`, because that is what makes a link in a // mail about last Friday open last Friday rather than next Friday. assert.match(urls[0].example, /^\/site\/events\/[^?]+\?run=/, `${t.id}`) // Optional, so `email.button` drops itself rather than rendering an inert // grey label when the event is not public and there is no page. assert.equal(urls[0].required, false, `${t.id}`) } } }) test('the six public triggers are at version 2 — the url variable is a declaration change', () => { // A variable added to a declaration is a version bump, not a correction: a rule // written against version 1 was written against a payload with no link in it. // `run.failed` gained nothing and stays where it was. for (const t of eventTriggers()) { assert.equal(t.version, t.id === 'event.run.failed' ? 1 : 2, `${t.id}`) } }) 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`) } } })