Files
website/server/test/moduleRegistries.test.js
wtclaude 7d3d6d5abd
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
feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`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)
2026-09-04 13:06:46 -05:00

253 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The three de-entanglement registries ───────────────────────────────────
//
// Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. The properties worth a test
// are the ones nobody exercises by hand: what happens when two registrants want
// the same name, and what is left behind when one of them fails halfway.
//
// Point the DB at a closed port BEFORE requiring anything — registerCore() pulls
// in the announce legs, which pull in models that build a pool at require time.
// No query is ever run.
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 db = require('../src/utils/db')
// Declares `admin.users.detail` the way production does — at require time, in the
// router that owns the resource.
require('../src/router/v1/admin')
after(() => db.close())
beforeEach(() => registries._reset())
const stream = (id, over = {}) => ({ id, label: id, ...over })
const leg = (id, over = {}) => ({ leg: id, label: id, dispatch: async () => ({ ok: true }), classify: () => ({ outcome: 'done' }), ...over })
/** Register a batch as `owner` and return the error message, or null on success. */
function tryApply(owner, build) {
const api = registries.stage(owner)
try {
build(api)
registries.apply(api.staged)
return null
} catch (err) {
return err.message
}
}
// ── Core goes through the same door ────────────────────────────────────────
test('registerCore registers exactly what core owns, and nothing else', () => {
registries.registerCore()
// Five streams, one leg, no filled slot. Before Phase 3 this was eight streams,
// two legs and a core-filled `admin.users.detail` — core was holding shard
// CONTENT so the seam would be exercised on every boot before a module first
// used it. module-uo registers all of it now, through the same door.
//
// The four `team.*` streams arrived with Teams phase 6 and ARE core's: a module
// 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)
assert.equal(registries.isCoreRegistered(), true)
})
test('registerCore is idempotent — a second call registers nothing twice', () => {
registries.registerCore()
const before = registries.allStreams().length
registries.registerCore()
assert.equal(registries.allStreams().length, before)
})
test('the wire shape of a stream survives registration', () => {
// Asserted against a REGISTERED stream rather than a core one, because the
// shape is what a module hands over and core republishes. `vendor.sale` used
// to be the subject here and is module-uo's now; a synthetic registration
// tests the same contract without core needing a personal stream of its own.
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([{
id: 'uo.vendorsale',
label: 'Vendor sales',
description: 'One of your vendors sold something.',
personal: true,
requiresLinkedAccount: true,
}])), null)
const personal = registries.allStreams().find((s) => s.id === 'uo.vendorsale')
// Two booleans, not the contract's single `scope`: this object is the body of
// GET /auth/me/notifications/streams and a shipped Android client reads both.
assert.equal(personal.personal, true)
assert.equal(personal.requiresLinkedAccount, true)
assert.ok(personal.description.length > 0)
assert.ok(registries.personalStreams().has('uo.vendorsale'))
assert.equal(registries.isValidStream('uo.vendorsale'), true)
assert.equal(registries.isValidStream('nope.nope'), false)
})
// ── Namespacing ────────────────────────────────────────────────────────────
test('a modules stream ids must carry its module id', () => {
assert.equal(tryApply('rust', (api) => api.registerNotificationStreams([stream('rust.raid')])), null)
assert.match(
tryApply('rust', (api) => api.registerNotificationStreams([stream('raid.started')])),
/not namespaced "rust\."/,
)
})
test('the seven pre-module-system stream ids are grandfathered to uo alone', () => {
// Renaming them would be a data migration (notification_subs rows) and a break
// for a shipped Android client — the same reasoning as the loader's legacy
// table prefixes.
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([stream('vendor.sale')])), null)
registries._reset()
assert.match(
tryApply('rust', (api) => api.registerNotificationStreams([stream('vendor.sale')])),
/not namespaced "rust\."/,
)
})
test('an announce leg must be namespaced too, with towncrier grandfathered to uo', () => {
assert.equal(tryApply('uo', (api) => api.registerAnnounceLeg(leg('towncrier'))), null)
registries._reset()
assert.equal(tryApply('rust', (api) => api.registerAnnounceLeg(leg('rust.motd'))), null)
registries._reset()
assert.match(
tryApply('rust', (api) => api.registerAnnounceLeg(leg('towncrier'))),
/not namespaced "rust\."/,
)
})
// ── Collisions name the holder ─────────────────────────────────────────────
test('a stream core already registered is refused, naming core', () => {
registries.registerCore()
assert.match(
tryApply('uo', (api) => api.registerNotificationStreams([stream('news.post')])),
/already registered by "core"/,
)
})
test('two modules cannot register the same stream or leg', () => {
assert.equal(tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])), null)
// A second module can only reach it via its own namespace, so collide on a
// grandfathered id, which is the realistic case.
assert.match(
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])),
/already registered by "aaa"/,
)
assert.equal(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), null)
assert.match(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), /already registered by "bbb"/)
})
test('a batch cannot claim the same name twice', () => {
assert.match(
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.x'), stream('aaa.x')])),
/registered twice/,
)
})
// ── Validate-then-commit ───────────────────────────────────────────────────
test('a batch whose LAST claim collides commits none of the earlier ones', () => {
// The property the whole staging design exists for. A half-registered catalog
// is worse than a missing one: a subscribable stream nothing will publish to.
registries.registerCore()
const before = registries.allStreams().length
const err = tryApply('uo', (api) => {
api.registerNotificationStreams([stream('uo.first'), stream('uo.second')])
api.registerAnnounceLeg(leg('uo.leg'))
api.registerNotificationStreams([stream('news.post')]) // collides with core
})
assert.match(err, /already registered by "core"/)
assert.equal(registries.allStreams().length, before, 'uo.first / uo.second must not be registered')
assert.equal(registries.isValidStream('uo.first'), false)
assert.equal(registries.announceLeg('uo.leg'), null)
})
test('staging alone changes nothing — only apply() commits', () => {
const api = registries.stage('uo')
api.registerNotificationStreams([stream('uo.staged')])
assert.equal(registries.isValidStream('uo.staged'), false)
registries.apply(api.staged)
assert.equal(registries.isValidStream('uo.staged'), true)
})
// ── Shape checks fire at the call ──────────────────────────────────────────
test('a malformed claim throws where the registrant made it, not at apply()', () => {
const api = registries.stage('uo')
assert.throws(() => api.registerNotificationStreams([stream('nodots')]), /bad stream id/)
assert.throws(() => api.registerNotificationStreams([{ id: 'uo.x' }]), /has no label/)
assert.throws(() => api.registerNotificationStreams('not an array'), /expected an array/)
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { dispatch: null })), /has no dispatch/)
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { classify: null })), /has no classify/)
assert.throws(() => api.registerAnnounceLeg({ leg: 'NOPE' }), /bad leg id/)
})
// ── Extension slots ────────────────────────────────────────────────────────
test('only a declared slot can be filled, and only once', () => {
const router = () => {}
const api = registries.stage('uo')
assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)
// Core no longer fills it — module-uo does. Two registrants racing for the
// same slot is still the case worth testing, so the first fill is a module's.
assert.equal(tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)), null)
assert.match(
tryApply('rust', (a) => a.registerExtension('admin.users.detail', router)),
/already filled by "uo"/,
)
})
test('a slot cannot be declared twice', () => {
assert.throws(() => registries.declareSlot('admin.users.detail'), /already declared/)
})
// ── The slot's spec, which static analysis cannot see ──────────────────────
test('the filled slots router is findable in the live app, at the resource path', () => {
// Guards swagger/slotSpecs.js: it recovers each slot's mount prefix from the
// live stack rather than a hardcoded table. If this stops working, the six
// slot routes vanish from swagger-output.json with `Success` printed — the
// exact silent failure the spike hit (MODULE_API.md §7.4).
/* eslint-disable global-require */
const app = require('../src/app')
const { findMountPrefix } = require('../swagger/slotSpecs')
/* eslint-enable global-require */
// The slot is DECLARED by core and filled by whichever module is installed —
// none, in core's own test run. What must keep working regardless is the
// recovery of its mount prefix from the live stack, because that is what
// slotSpecs.js needs and what fails silently when it breaks.
const router = registries.declaredSlotRouter('admin.users.detail')
assert.ok(router, 'core declares the slot at require time, filled or not')
assert.equal(findMountPrefix(app._router.stack, router), '/api/v1/admin/users/:id')
})