`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)
312 lines
14 KiB
JavaScript
312 lines
14 KiB
JavaScript
// ── 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)
|
|
})
|