feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
Some checks failed
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Failing after 5m47s
PR Checks / bot-tests (pull_request) Successful in 8m27s

`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:
2026-09-04 13:06:46 -05:00
parent d4516739b4
commit 7d3d6d5abd
36 changed files with 2960 additions and 39 deletions

View File

@@ -701,6 +701,48 @@ test('the ceiling is re-checked at SEND time, so a module narrowing its declarat
assert.equal(after2.enqueued, 0)
})
test('a FIRING may narrow the ceiling, and every rule wider than that is refused', async () => {
// EVENTS.md §I, and the case that forced it: a rehearsal fires exactly the
// same trigger as the real thing, so without a per-firing bound, rehearsing a
// published event mails every subscriber it. The declaration is a property of
// the KIND of event; this is a property of the occasion.
registries._reset()
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
addUser(11)
optIn(10, 'uo.house.idoc_warning', 'email')
optIn(11, 'uo.house.idoc_warning', 'email')
addRule({ audience: 'authenticated' })
const wide = await engine.dispatch(event(), T0)
assert.equal(wide.enqueued, 2)
const narrowed = await engine.dispatch(event({ subject: 'house-9', ceiling: 'staff' }), later(1000))
assert.equal(narrowed.enqueued, 0)
})
test('a firing may only ever NARROW — a wider ceiling than the declaration changes nothing', async () => {
registries._reset()
registerUoTrigger({ ceiling: 'owner', audience: 'owner' })
addRule({ audience: 'owner' })
// `everyone` is the widest value the lattice has. `meet(owner, everyone)` is
// `owner`, so the declaration still governs and the rule still fires.
const out = await engine.dispatch(event({ ceiling: 'everyone' }), T0)
assert.equal(out.enqueued, 1)
})
test('two INCOMPARABLE ceilings meet to nothing and the gate refuses rather than guessing', async () => {
// `owner` and `staff` have no common descendant — "fewer people" is not "less
// exposure", which is the whole argument `modules/ceilings.js` is built on.
// Picking one would be the guess §5.1a rule 3 exists to refuse.
registries._reset()
registerUoTrigger({ ceiling: 'owner', audience: 'owner' })
addRule({ audience: 'owner' })
const out = await engine.dispatch(event({ ceiling: 'staff' }), T0)
assert.equal(out.enqueued, 0)
})
test('a rule for an unregistered trigger is dormant, not deleted and not an error', async () => {
const rule = addRule({ trigger_id: 'uo.gone.away' })
const listed = await rules.listAnnotated()

View File

@@ -76,15 +76,50 @@ function register(owner, fn) {
// ── Core's own declarations ────────────────────────────────────────────────
test('core registers its five triggers, and they are the five stream ids', () => {
test('every core STREAM is also a trigger, and one namespace holds both', () => {
// §7.2's one namespace, and what Events Phase 10 taught it. Until then the two
// sets were identical and the test asserted that; they are not identical any
// more and were never required to be. **A stream is a push toggle and a
// trigger is a payload contract**, and the rule is containment in one
// direction only: an id that can be pushed must have something to say, so
// every stream is a trigger — while a trigger that is not a stream is simply
// one nothing wakes a phone for, which is six of the seven event triggers.
//
// The direction matters. A STREAM with no trigger would be a push toggle for
// an event no rule can fire; a trigger with no stream is mail and an inbox row
// and no tickle, which is a complete and useful notification.
registries.registerCore()
const triggerIds = registries.allTriggers().map((t) => t.id).sort()
const streamIds = registries.allStreams().map((s) => s.id).sort()
assert.deepEqual(triggerIds, streamIds)
assert.deepEqual(streamIds.filter((id) => !triggerIds.includes(id)), [])
assert.deepEqual(triggerIds, [
'event.phase.changed', 'event.run.cancelled', 'event.run.completed',
'event.run.ending', 'event.run.failed', 'event.run.scheduled',
'event.run.started',
'news.post', 'team.announcement', 'team.forum.post',
'team.leadership.changed', 'team.member.joined',
])
// The one event id that is BOTH (org lead, 2026-09-04). Push is the channel
// that says "now", and this is the only lifecycle moment worth waking a phone
// for. A rule naming `push` on any of the other six would enqueue a tickle
// nobody can subscribe to — the defect the live walk found.
assert.deepEqual(streamIds, [
'event.run.started',
'news.post', 'team.announcement', 'team.forum.post',
'team.leadership.changed', 'team.member.joined',
])
})
test('only a stream offers the push channel, which is why run.started is one', () => {
// eslint-disable-next-line global-require
const prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model')
registries.registerCore()
const byId = Object.fromEntries(prefs.catalog({ role: 'admin' }).map((i) => [i.id, i]))
assert.ok(byId['event.run.started'].channels.includes('push'))
assert.ok(!byId['event.run.completed'].channels.includes('push'))
// …and a trigger-only id still reaches a person on the two content channels.
assert.ok(byId['event.run.completed'].channels.includes('email'))
assert.ok(byId['event.run.completed'].channels.includes('inapp'))
})
test('the four Team triggers ceiling at members — a private forum excerpt cannot be widened', () => {
@@ -413,7 +448,8 @@ test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling
registries.registerCore()
const res = mockRes()
ctrl.listTriggers({}, res)
assert.equal(res.body.triggers.length, 5)
// Five, plus Events Phase 10's seven.
assert.equal(res.body.triggers.length, 12)
const news = res.body.triggers.find((t) => t.id === 'news.post')
assert.equal(news.owner, 'core')
assert.ok(news.variables.some((v) => v.name === 'title' && v.example))

View File

@@ -44,7 +44,7 @@ const register = (owner, entries) => {
registries.apply(api.staged)
}
test('core registers its four actions on every boot', () => {
test('core registers its six actions on every boot', () => {
registries.registerCore()
const ids = registries.allEventActions().map((a) => a.id)
// `core.lease` joined the three in Phase 8, and it is the only one of the four
@@ -52,7 +52,20 @@ test('core registers its four actions on every boot', () => {
// module's: §F puts the duration bound and the two-events-one-target conflict
// check on core's side of the seam, and a lease verb per module would be that
// bound re-implemented once per module and advisory everywhere.
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue', 'core.lease'])
//
// Phase 10 added the last two, and they are the integrations (§J):
// `core.announce.post` sends an ARTICLE through the announce legs rather than
// a line, and `core.results.publish` is what makes §F's "publish results"
// literal. Six is now the whole of what an event can do on a deployment with
// no game module installed at all.
assert.deepEqual(ids, [
'core.announce',
'core.wait',
'core.cue',
'core.lease',
'core.announce.post',
'core.results.publish',
])
assert.equal(ids.length, coreEventActions.ACTIONS.length)
})
@@ -248,7 +261,7 @@ test('a whole batch is refused or taken, never half', () => {
test('_reset() hands the process back', () => {
registries.registerCore()
assert.equal(registries.allEventActions().length, 4)
assert.equal(registries.allEventActions().length, 6)
registries._reset()
assert.equal(registries.allEventActions().length, 0)
assert.equal(registries.isEventAction('core.wait'), false)
@@ -378,6 +391,10 @@ test('an option source needs a resolver, and core registers one of its own', ()
// param would otherwise be a free-text box whose typo is caught at dispatch,
// mid-run — and the leases are already a registry with labels in them.
'core.options.leases',
// Phase 10's, and the first core source that reaches a table. It is allowed
// to because of WHEN a source resolves: on its own request, on a booted
// server, rather than at `register()` time under a dead pool.
'core.options.posts',
])
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
assert.equal(leg.source, 'core.options.legs')

View 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`)
}
}
})

View File

@@ -0,0 +1,311 @@
// ── The integrations (EVENTS_PLAN.md Phase 10) ─────────────────────────────
//
// §J's three reuse claims, each turned into something that fails when the reuse
// stops being real:
//
// • **`core.announce.post` reuses the announce legs** rather than growing a
// second delivery pipeline — so the assertions are about what it puts in
// `announce_jobs`, not about what reaches Discord.
// • **an event's job must not stand on the news pipeline's toes.** A post may
// now have two jobs, and everything that reads "the post's job" — the admin
// panel, its retry button, `announced_at` — must still mean the news one.
// This is the half that would be silent: nothing errors, the panel just
// starts showing a different row.
// • **`core.results.publish` is idempotent**, because it is an ordinary step
// an author may place twice and the runner may retry.
//
// And the two seeded rules, checked against the declarations they name. A seeded
// rule pointing at a template that does not exist, or at an audience its own
// trigger's ceiling forbids, is a rule that fails on the first firing after an
// operator switches it on — which is the worst possible moment to find out.
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 registries = require('../src/modules/registries')
const ceilings = require('../src/modules/ceilings')
const coreRules = require('../src/engagement/coreRules')
const templateSeeds = require('../src/engagement/templateSeeds')
const { TRIGGERS } = require('../src/config/coreTriggers')
const postsDb = require('../src/model/posts/posts.db')
const postsModel = require('../src/model/posts/posts.model')
const announceDb = require('../src/model/announceJobs/announceJobs.db')
const announceModel = require('../src/model/announceJobs/announceJobs.model')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const runsDb = require('../src/model/events/eventRuns.db')
const db = require('../src/utils/db')
after(() => db.close())
// ── core.announce.post ─────────────────────────────────────────────────────
const POSTS = {
1: { id: 1, category: 'news', title: 'The Yew Invasion', published: 1, announce_job_id: 9 },
2: { id: 2, category: 'news', title: 'A draft', published: 0, announce_job_id: null },
3: { id: 3, category: 'newsletter', title: 'Never announced', published: 1, announce_job_id: null },
}
let enqueued
let linked
const originals = {
getById: postsModel.getById,
link: postsModel.linkAnnounceJob,
create: announceDb.create,
legIds: registries.announceLegIds,
}
beforeEach(() => {
registries._reset()
registries.registerCore()
enqueued = []
linked = []
postsModel.getById = async (id) => POSTS[id] || null
postsModel.linkAnnounceJob = async (id, jobId) => {
linked.push({ id, jobId })
}
announceDb.create = async (postId, legs, opts = {}) => {
enqueued.push({ postId, legs, runId: opts.runId ?? null })
return 100 + enqueued.length
}
})
after(() => {
postsModel.getById = originals.getById
postsModel.linkAnnounceJob = originals.link
announceDb.create = originals.create
registries.announceLegIds = originals.legIds
})
const announcePost = () => registries.eventAction('core.announce.post')
test('a published post is queued on every registered leg, tagged with the run', async () => {
const answer = await announcePost().perform({ runId: 3692, params: { postId: 1 }, verify: false })
assert.deepEqual(answer, { ok: true })
assert.equal(enqueued.length, 1)
assert.equal(enqueued[0].postId, 1)
assert.equal(enqueued[0].runId, 3692)
// The legs come from the registry, so a module's leg is included without this
// action naming one — which is the whole of "reuse the legs".
assert.deepEqual(enqueued[0].legs, registries.announceLegIds())
})
test('an unpublished post is refused, and refused terminally', async () => {
// A draft has no public page for a town-crier line to point at, and
// announcing one would publish its title to a shard before an editor meant
// to. Publishing is the CMS's decision and this action is not it.
const answer = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: false })
assert.equal(answer.ok, false)
assert.equal(answer.retry, false)
assert.match(answer.error, /not published/)
assert.equal(enqueued.length, 0)
})
test('a post id that names nothing is refused terminally — it will not appear in sixty seconds', async () => {
for (const bad of [999, 0, -1, 'twelve', null]) {
const answer = await announcePost().perform({ runId: 1, params: { postId: bad }, verify: false })
assert.equal(answer.ok, false, `${bad}`)
assert.equal(answer.retry, false, `${bad}`)
}
assert.equal(enqueued.length, 0)
})
test('a dry run checks the post and queues nothing', async () => {
const answer = await announcePost().perform({ runId: 1, params: { postId: 1 }, verify: true })
assert.deepEqual(answer, { ok: true })
assert.equal(enqueued.length, 0)
// …and still refuses what the real run would refuse, which is the point of a
// dry run: the cost of finding out is a page, not a half-changed world.
const refused = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: true })
assert.equal(refused.ok, false)
})
test('the post\'s back-pointer is left alone when it already has one', async () => {
// `announce_job_id` is what the post admin panel reads and what
// `shouldEnqueue` guards on. Moving it to an event's job would make a
// re-published post announce itself again.
await announceModel.enqueueForRun(1, 3692)
assert.deepEqual(linked, [])
})
test('a post that has never been announced gains the pointer, because this IS its announcement', async () => {
await announceModel.enqueueForRun(3, 3692)
assert.equal(linked.length, 1)
assert.equal(linked[0].id, 3)
})
// ── the news pipeline is untouched ─────────────────────────────────────────
test('"the post\'s job" still means the news one, however many an event has added', async () => {
// The half that would be silent: nothing errors, the admin panel just starts
// rendering an event's job and its retry button retries that instead.
//
// Asserted against the query's own text rather than by stubbing the pool.
// `announceJobs.db.js` destructures `query` at require time, so a stub on the
// db module here would replace something nothing reads — and the test would
// then pass whatever the SQL said, which is the one thing it exists to check.
const source = require('node:fs').readFileSync(
require.resolve('../src/model/announceJobs/announceJobs.db'),
'utf8',
)
const fn = source.slice(source.indexOf('async function findByPostId'))
assert.match(fn.slice(0, fn.indexOf('\n}')), /run_id IS NULL/)
})
test('a run\'s job does not restamp the post\'s announced_at', async () => {
// `announced_at` means "when this post was announced". An event linking a
// three-week-old article would otherwise rewrite that to today, and the admin
// panel would report a publication date the post does not have.
const stamped = []
const saved = { findById: announceDb.findById, setStatus: announceDb.setStatus, markAnnounced: postsModel.markAnnounced }
announceDb.findById = async (id) => ({
id,
post_id: 1,
run_id: id === 1 ? null : 3692,
status: 'pending',
legs: [{ leg: 'discord', status: 'done' }],
})
announceDb.setStatus = async () => {}
postsModel.markAnnounced = async (postId) => {
stamped.push(postId)
}
try {
await announceModel.refreshStatus(2) // a run's job
assert.deepEqual(stamped, [])
await announceModel.refreshStatus(1) // the news pipeline's own
assert.deepEqual(stamped, [1])
} finally {
Object.assign(announceDb, { findById: saved.findById, setStatus: saved.setStatus })
postsModel.markAnnounced = saved.markAnnounced
}
})
// ── core.results.publish ───────────────────────────────────────────────────
test('publishing ranks the run\'s participants and stamps it, and does neither on a dry run', async () => {
const calls = []
const saved = { rank: participantsDb.rankRun, mark: runsDb.markResultsPublished }
participantsDb.rankRun = async (id) => {
calls.push(['rank', id])
return 3
}
runsDb.markResultsPublished = async (id) => {
calls.push(['stamp', id])
return true
}
try {
const action = registries.eventAction('core.results.publish')
assert.deepEqual(await action.perform({ runId: 3692, verify: true }), { ok: true })
assert.deepEqual(calls, [])
assert.deepEqual(await action.perform({ runId: 3692, verify: false }), { ok: true })
assert.deepEqual(calls, [['rank', 3692], ['stamp', 3692]])
// Idempotent by construction: the ranking is a total order over
// `(score, joined_at, id)`, so a second publication writes the same numbers.
// That is what makes it safe as an ordinary retried step.
await action.perform({ runId: 3692, verify: false })
assert.equal(calls.length, 4)
} finally {
participantsDb.rankRun = saved.rank
runsDb.markResultsPublished = saved.mark
}
})
test('publishing takes no params — a run may not publish somebody else\'s results', async () => {
assert.deepEqual(registries.eventAction('core.results.publish').params, [])
})
test('publishing is `inspect`, so it is default-ON and an author can place it unaided', () => {
// Nothing in the game world changes and nobody is messaged: a table core
// already holds becomes readable.
const action = registries.eventAction('core.results.publish')
assert.equal(action.risk, 'inspect')
assert.equal(action.reversible, 'none')
})
// ── the option source ──────────────────────────────────────────────────────
test('the posts option source answers value/label/group from published posts only', async () => {
const saved = postsDb.listPublishedForOptions
postsDb.listPublishedForOptions = async () => [
{ id: 1, category: 'news', title: 'The Yew Invasion' },
{ id: 3, category: 'newsletter', title: 'Never announced' },
]
try {
// Through the resolver the catalog route uses, so the normalisation it
// applies — every value stringified for the `<select>` — is exercised too
// rather than only the raw `resolve()`.
const answer = await registries.resolveOptionSource('core.options.posts')
assert.equal(answer.ok, true)
assert.deepEqual(answer.options, [
{ value: '1', label: 'The Yew Invasion', group: 'news' },
{ value: '3', label: 'Never announced', group: 'newsletter' },
])
} finally {
postsDb.listPublishedForOptions = saved
}
})
// ── the seeded rules ───────────────────────────────────────────────────────
const declared = (id) => TRIGGERS.find((t) => t.id === id)
const seededKeys = new Set(templateSeeds.SEEDS.map((s) => s.key))
test('two rules are seeded, both for triggers core declares', () => {
assert.equal(coreRules.EVENT_RULES.length, 2)
for (const rule of coreRules.EVENT_RULES) {
assert.ok(declared(rule.trigger_id), `${rule.trigger_id} is not declared`)
}
})
test('every seeded rule names templates that exist', () => {
// A rule pointing at a template that does not exist fails on the first firing
// after an operator switches it on, which is the worst moment to find out.
for (const rule of coreRules.EVENT_RULES) {
for (const [channel, key] of Object.entries(rule.template_keys)) {
assert.ok(seededKeys.has(key), `${rule.trigger_id}.${channel} names "${key}", which is not seeded`)
}
}
})
test('no seeded rule asks for an audience its trigger\'s ceiling forbids', () => {
for (const rule of coreRules.EVENT_RULES) {
const trigger = declared(rule.trigger_id)
assert.ok(
ceilings.permits(trigger.ceiling, rule.audience),
`${rule.trigger_id} is ceilinged ${trigger.ceiling} and the seeded rule asks for ${rule.audience}`,
)
}
})
test('the failure rule stays at admin and carries no push', () => {
const failed = coreRules.EVENT_RULES.find((r) => r.trigger_id === 'event.run.failed')
assert.equal(failed.audience, 'admin')
// An admin's phone buzzing at four in the morning for a step that will still
// be failed at breakfast is a notification people switch off wholesale — and
// switching it off wholesale is how the one that mattered is missed.
assert.ok(!failed.channels.includes('push'))
// No cooldown, and it is the one rule here that must not have one: the subject
// is the run, so a cooldown would only ever suppress a second failure of the
// very run an administrator most needs the second line about.
assert.equal(failed.cooldown_seconds, 0)
})
test('every seeded rule carries an hourly ceiling — a module may choose the number, not decline one', () => {
for (const rule of coreRules.EVENT_RULES) {
assert.ok(Number.isInteger(rule.max_sends_per_hour) && rule.max_sends_per_hour > 0, rule.trigger_id)
}
})
test('the event rules have their OWN one-shot key, so an upgraded deployment still gets them', () => {
// The Team key is stamped on every deployment that has booted since Phase 6
// and the news key on every one since Phase 11. Appending to either list would
// seed these on fresh installs only, and on exactly the upgrades that want
// them, never.
const keys = [coreRules.SEEDED_KEY, coreRules.NEWS_SEEDED_KEY, coreRules.EVENT_SEEDED_KEY]
assert.equal(new Set(keys).size, 3)
})

View File

@@ -0,0 +1,193 @@
// ── 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)
})

View File

@@ -40,6 +40,9 @@ const gatesDb = require('../src/model/events/eventPhaseGates.db')
// a new leg under a model needs a stub in every file that stubs that layer.
const resourcesDb = require('../src/model/events/eventRunResources.db')
const eventCleanup = require('../src/events/cleanup')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const engagementEmit = require('../src/utils/engagementEmit')
const db = require('../src/utils/db')
after(() => db.close())
@@ -55,10 +58,24 @@ const originals = [
['gates', gatesDb, { ...gatesDb }],
['resources', resourcesDb, { ...resourcesDb }],
['cleanup', eventCleanup, { ...eventCleanup }],
// Phase 10: `cancel` now announces, and `events/announce.js` reads the
// definition. Left unstubbed every cancel here would wait out the dead-port
// pool. Stubbed rather than silenced, so the announce path runs for real and
// `store.emits` can assert it fired after the guarded transition and not
// before it.
['definitions', definitionsDb, { ...definitionsDb }],
['participants', participantsDb, { ...participantsDb }],
['emit', engagementEmit, { ...engagementEmit }],
]
function installStubs() {
store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, nextStepId: 1, nextGateId: 1 }
store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, emits: [], nextStepId: 1, nextGateId: 1 }
definitionsDb.getById = async (id) => ({ id, title: `definition ${id}`, summary: null, series_name: null, timezone: 'UTC' })
participantsDb.countForRun = async () => 0
engagementEmit.emit = (owner, triggerId, envelope) => {
store.emits.push({ owner, triggerId, envelope })
return { ok: true }
}
const snap = (o) => ({ ...o })
runsDb.setCleanupStatus = async (id, to, from = null) => {
@@ -591,6 +608,18 @@ test('an empty reason is stored as NULL rather than as an empty string', async (
// ── cancel decides what happens to the world (Phase 8) ─────────────────────
/** The `run.status` line for one run — Phase 10 stopped it being the last one. */
const statusLine = (id) => store.log.filter((l) => l.runId === id && l.kind === 'run.status').at(-1)
test('a refused cancel announces nothing at all', async () => {
// The emit is after the guarded transition, so the loser of a race between two
// moderators pressing cancel has already returned a 409 and said nothing.
const id = seedRun({ status: 'completed', steps: [] })
const refused = await controls.cancel(id, { reason: 'too late' }, ACTOR)
assert.equal(refused.ok, false)
assert.deepEqual(store.emits, [])
})
test('cancel gives back what the run took, by default and without waiting for it', async () => {
// The teardown is the runner cleanup leg over TERMINAL runs, not this request.
// Two reasons, and both are why the control answers at once: a cancel pressed
@@ -609,7 +638,15 @@ test('cancel gives back what the run took, by default and without waiting for it
// Still `pending`, which is what the leg looks for. The run is terminal the
// moment this returns, so the very next tick picks its ledger up.
assert.equal(runRow(id).cleanup_status, 'pending')
assert.equal(store.log.at(-1).detail.cleanup, true)
// The status line, found by kind rather than by being last: Phase 10 put an
// `announcement.emitted` line after it, because the announcement genuinely
// happens after the guarded transition.
assert.equal(statusLine(id).detail.cleanup, true)
// …and the run announced its own cancellation, with the operator's reason and
// not the diagnostic string that ends up in `last_error`.
assert.deepEqual(store.emits.map((e) => e.triggerId), ['event.run.cancelled'])
assert.equal(store.emits[0].envelope.data.reason, 'called off')
})
test('cancel WITHOUT cleanup is admin-only, even though the route is wider', async () => {
@@ -646,8 +683,8 @@ test('cancel without cleanup leaves the world changes up, and says so on the run
assert.equal(result.ok, true)
assert.equal(result.cleanup, false)
assert.equal(runRow(id).cleanup_status, 'incomplete')
assert.equal(store.log.at(-1).detail.cleanup, false)
assert.equal(store.log.at(-1).detail.by, ACTOR)
assert.equal(statusLine(id).detail.cleanup, false)
assert.equal(statusLine(id).detail.by, ACTOR)
})
test('a run that recorded nothing is unaffected by either flag', async () => {

View File

@@ -51,6 +51,8 @@ const budgetDb = require('../src/model/events/eventRunBudget.db')
// it is a ten-second wait on the dead port.
const resourcesDb = require('../src/model/events/eventRunResources.db')
const gates = require('../src/events/gates')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const engagementEmit = require('../src/utils/engagementEmit')
const db = require('../src/utils/db')
after(() => db.close())
@@ -63,7 +65,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
let store
const originals = {}
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb]]) {
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb], ['participantsDb', participantsDb], ['engagementEmit', engagementEmit]]) {
originals[name] = { mod, fns: { ...mod } }
}
@@ -98,6 +100,33 @@ function installStubs() {
// connection timeout.
Object.assign(definitionsDb, { findSchedulable: async () => [] })
// ── The lifecycle announcements (Phase 10) ──
//
// `events/announce.js` reads the definition on every transition, so left
// unstubbed every emit here would wait out the dead-port pool — ten seconds a
// transition, on a file whose whole subject is how many ticks a thing takes.
// Stubbed rather than silenced: the announce path runs for REAL against these,
// which is what lets `emits()` below assert that the wiring is where it should
// be. Two runner tests turn on it, and a third would have caught the wiring
// being on the losing side of a compare-and-set.
definitionsDb.getById = async (id) => ({
id,
title: `definition ${id}`,
summary: null,
series_name: null,
timezone: 'UTC',
})
participantsDb.countForRun = async () => 0
store.participants = []
participantsDb.record = async (row) => {
store.participants.push(row)
}
store.emits = []
engagementEmit.emit = (owner, triggerId, envelope) => {
store.emits.push({ owner, triggerId, envelope })
return { ok: true }
}
// ── The resource ledger (Phase 8) ──
//
// `reserve` enforces `uq_evres_target` in the stub, because the refusal it
@@ -535,6 +564,7 @@ const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params,
const run = (id) => store.runs.get(id)
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind)
const emits = () => store.emits.map((e) => e.triggerId)
const gateOf = (id, phase) => store.gates.get(`${id}|${phase}`)
// A registered test action whose behaviour the test dictates.
@@ -658,6 +688,103 @@ test('a run passes through `ending` on its way to completed', async () => {
assert.deepEqual(transitions, ['starting', 'running', 'ending', 'completed'])
})
// ── The lifecycle announcements (Phase 10) ─────────────────────────────────
//
// §J: a run says what happened and an operator's rule decides who is told. What
// belongs in THIS file is only that the runner says it at the right moments —
// after the guarded transition, once, and not for the phase `run.started`
// already covered.
test('a run announces its lifecycle: started, each LATER phase, ending, completed', async () => {
register([scriptedAction('test.noop')])
const id = seedRun([
{ key: 'opening', label: 'Opening', steps: [step('test.noop')] },
{ key: 'closing', label: 'Closing', steps: [step('test.noop')] },
])
await runner.tick(T0)
// The FIRST phase does not fire `phase.changed`. `run.started` already said the
// event began, and a deployment with a rule on each would announce the opening
// twice, seconds apart, saying the same thing.
assert.deepEqual(emits(), [
'event.run.started',
'event.phase.changed',
'event.run.ending',
'event.run.completed',
])
const changed = store.emits.find((e) => e.triggerId === 'event.phase.changed')
assert.equal(changed.envelope.data.phase, 'closing')
assert.equal(changed.envelope.data.phaseIndex, 2)
assert.equal(changed.envelope.data.phaseCount, 2)
// Keyed on the run, so a weekly event is not throttled by last week's.
assert.equal(changed.envelope.subject, String(id))
assert.equal(changed.envelope.scopeKey, `event:${id}`)
})
test('a REHEARSAL announces the same things, ceilinged to staff', async () => {
// §I. Emitting nothing would be a rehearsal of everything except the
// announcements, which are the part most worth rehearsing.
register([scriptedAction('test.noop')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }])
store.runs.get(id).rehearsal = 1
await runner.tick(T0)
assert.deepEqual(emits(), ['event.run.started', 'event.run.ending', 'event.run.completed'])
assert.ok(store.emits.every((e) => e.envelope.ceiling === 'staff'))
})
test('a failed run announces the failure and nothing else', async () => {
register([scriptedAction('test.boom')])
scripted['test.boom'] = { calls: [], answer: { ok: false, retry: false, error: 'the shard said no' } }
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.boom', {}, 'abort_run')] }])
await runner.tick(T0)
assert.equal(run(id).status, 'failed')
assert.deepEqual(emits(), ['event.run.started', 'event.run.failed'])
const failed = store.emits.find((e) => e.triggerId === 'event.run.failed')
assert.equal(failed.envelope.data.error, 'the shard said no')
assert.equal(failed.envelope.data.runUrl, `/admin/events/runs/${id}`)
})
test('a step reporting participants records them, and a bad one does not fail the step', async () => {
register([scriptedAction('test.collect')])
scripted['test.collect'] = {
calls: [],
answer: {
ok: true,
participants: [{ memberKey: 'darrow', score: 12, userId: 4 }, { score: 3 }],
},
}
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.collect')] }])
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'done')
assert.deepEqual(store.participants.map((p) => p.memberKey), ['darrow'])
assert.equal(store.participants[0].runId, id)
const line = store.log.find((l) => l.runId === id && l.kind === 'participants.recorded')
assert.equal(line.detail.recorded, 1)
assert.match(line.detail.rejected.join(' '), /bad memberKey/)
})
test('a run that completes counts the participants it recorded', async () => {
register([scriptedAction('test.collect')])
scripted['test.collect'] = { calls: [], answer: { ok: true, participants: [{ memberKey: 'darrow' }] } }
participantsDb.countForRun = async () => 1
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.collect')] }])
await runner.tick(T0)
const completed = store.emits.find((e) => e.triggerId === 'event.run.completed')
assert.equal(completed.envelope.data.participantCount, 1)
})
test('phases run in order and the next one is materialised on entry', async () => {
register([scriptedAction('test.noop')])

View File

@@ -49,6 +49,7 @@ const budgetDb = require('../src/model/events/eventRunBudget.db')
// one missing stub here cost the run detail test ten seconds and said nothing
// about the route it was testing.
const resourcesDb = require('../src/model/events/eventRunResources.db')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
const activity = require('../src/model/activity/activity.model')
@@ -71,6 +72,7 @@ for (const [name, mod] of [
['settingsDb', settingsDb],
['budgetDb', budgetDb],
['resourcesDb', resourcesDb],
['participantsDb', participantsDb],
['activity', activity],
]) {
originals[name] = { mod, fns: { ...mod } }
@@ -353,6 +355,13 @@ function installStubs() {
resourcesDb.forRun = async (runId) =>
store.resources.filter((r) => Number(r.run_id) === Number(runId))
// Phase 10: a run's detail now carries its participants. Unstubbed this is the
// ten-second dead-port wait, for the sixth time in this feature — and it would
// 500 the run console rather than saying anything about the route.
store.participants = []
participantsDb.listForRun = async (runId) =>
store.participants.filter((p) => Number(p.run_id) === Number(runId))
}
// ── Fixtures ───────────────────────────────────────────────────────────────
@@ -427,7 +436,10 @@ test('the catalog serves the registry, callables stripped, with its vocabularies
assert.equal(res.statusCode, 200)
assert.deepEqual(
res.body.actions.map((a) => a.id),
['core.announce', 'core.wait', 'core.cue', 'core.lease'],
// Phase 10's two are the integrations (EVENTS.md §J): `core.announce.post`
// sends an article through the announce legs rather than a line, and
// `core.results.publish` ranks and stamps the run's participants.
['core.announce', 'core.wait', 'core.cue', 'core.lease', 'core.announce.post', 'core.results.publish'],
)
for (const action of res.body.actions) assert.equal(action.perform, undefined)
assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
@@ -446,7 +458,7 @@ test('the catalog serves the registry, callables stripped, with its vocabularies
// their own.
assert.deepEqual(
res.body.optionSources.map((s) => s.id),
['core.options.legs', 'core.options.leases'],
['core.options.legs', 'core.options.leases', 'core.options.posts'],
)
for (const s of res.body.optionSources) assert.equal(s.resolve, undefined)
})
@@ -839,7 +851,19 @@ test('the board serves every registered action with its risk-class default, and
assert.equal(res.statusCode, 200)
const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a]))
assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.lease', 'core.wait'])
assert.deepEqual(Object.keys(byId).sort(), [
'core.announce',
'core.announce.post',
'core.cue',
'core.lease',
'core.results.publish',
'core.wait',
])
// `core.results.publish` is `inspect`, so like `core.wait` it arrives ENABLED:
// nothing in the game world changes and nobody is messaged, and an author
// should be able to place it without an admin first visiting this screen.
assert.equal(byId['core.results.publish'].enabled, true)
assert.equal(byId['core.results.publish'].changesWorld, false)
// And `core.lease` is the one core action the default-off rule bites: it is
// `change`, so a fresh deployment cannot borrow a value until an admin says so.
// §K's sentence, applied to core's own verb rather than only to a module's.

View File

@@ -53,12 +53,23 @@ test('registerCore registers exactly what core owns, and nothing else', () => {
// supplies who is in a Team, but who may be told about it is the access
// resolver's answer. Asserted as an exact list so a shard-content stream
// creeping back into core's registration fails here rather than shipping.
//
// **`event.run.started` joined them in Events Phase 10, and it is the one
// event trigger that is also a stream** (org lead, 2026-09-04). A stream is a
// PUSH toggle: `publishToUsers` joins `notification_subscriptions`, which is
// only ever written for an id the preferences screen offered push for, and
// that screen offers push only for a registered stream. So a rule naming
// `push` on a trigger-only id publishes a tickle to nobody while the send log
// records it sent — which is what the live walk found. Push is the channel
// that says *now*, so the one lifecycle moment worth waking a phone for gets
// it and the other six do not.
assert.deepEqual(registries.allStreams().map((s) => s.id), [
'news.post',
'team.member.joined',
'team.leadership.changed',
'team.forum.post',
'team.announcement',
'event.run.started',
])
assert.deepEqual(registries.announceLegIds(), ['discord'])
assert.equal(registries.slotFilledBy('admin.users.detail'), null)