`startsAtLabel` asked for `hour12: true` on `en-GB`. That is not the same request as a 12-hour clock, and it does not survive a Node upgrade: for a locale whose default cycle is h23, Node 20 resolves `hour12: true` to h11, whose hours run 0-11, so midnight renders "0:00 am". Node 22 and later resolve it to h12 and it renders "12:00 am". Same ICU on both sides, so it is V8's ECMA-402 behaviour rather than locale data. The image ships node:20-alpine and CI runs Node 20, while a dev machine is newer -- which is how this rendered correctly in front of everyone who wrote it and wrongly for every real recipient. An event mail announcing a midnight start said "0:00 am" while the schedule editor beside it said "12:00 AM": one instant, two spellings, which is the exact contradiction the option was added to prevent. `hourCycle: 'h12'` is the request that means what was meant. `recurrence.js` already states the mirror-image rule for `h23`, and every other formatter in this repo and in module-uo uses `hourCycle`; there is no `hour12` left here. This is the one test that has been red on every events PR since #192, and the only one -- each of those runs reported `# fail 1`. Verified by running the suite under node:20-alpine, where the test fails without this change and 2152 tests pass with it; on Node 22+ it passes either way, so the test's comment now says that a green run on a dev machine is not evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
370 lines
16 KiB
JavaScript
370 lines
16 KiB
JavaScript
// ── 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', () => {
|
|
// The hour cycle 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.
|
|
//
|
|
// This assertion only has teeth on the Node the image ships (20), where
|
|
// `hour12: true` resolves to h11 and midnight reads "0:00 am". On Node 22+ it
|
|
// passes either way — so a green run on a dev machine is not evidence, and CI
|
|
// is what actually holds this line. See the note beside `hourCycle` in
|
|
// events/announce.js.
|
|
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/<slug>`,
|
|
// 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`)
|
|
}
|
|
}
|
|
})
|