Files
website/server/test/eventAnnounce.test.js
wtclaude eb167558e3
Some checks failed
PR Checks / server-tests (pull_request) Failing after 5m57s
PR Checks / client-build (pull_request) Failing after 10m14s
PR Checks / bot-tests (pull_request) Successful in 8m36s
fix(events): staff could not reach their own participation history
Found by the live walk, signed in as an admin: /account/events redirected to
the dashboard. `GET /player/events/history` is behind requireAuth alone and
self-scoped on req.user.id -- staff are a superset of players -- but the WEB
has two logged-in shells, and RequirePlayer sends anyone who is not a
`player` out of /account. A single mount there is a screen the reviewing
admin can never open.

Engagement Phase 7 hit this exact wall with the inbox and answered it with
two routes, one pair of components and one mapping. `eventHistoryPath` joins
`inboxPath` and `notificationSettingsPath` in notificationPaths.js rather
than starting a second file with the same comment at the top of it. The
staff path is /admin/events/mine, in the Events section of the sidebar, and
it is the one row in that group with no `roles`.

Also: the eventAnnounce fixture carried no slug, state or `listed`, so
`eventUrl` answered undefined in every test in that file and the new code
was exercised by none of them. The fixture now looks like a definition row,
and three tests cover the link, the unlisted case and the draft case.

The run.failed assertion that came with them was reading the wrong layer:
`baseFor` assembles eventUrl for every trigger and the SEAM drops the keys a
trigger does not declare, so the declaration test is what proves it. Removed,
with a note saying where the rule actually lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 11:59:03 -05:00

364 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', () => {
// `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/<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`)
}
}
})