// ── The trigger registry, the audience registry, and the emit path ───────── // // ENGAGEMENT.md Phase 2's acceptance criteria, one test apiece: // // • core's triggers appear in GET /admin/engagement/triggers // • a module registering an un-namespaced trigger or audience fails, with the // holder named // • an audience whose module is uninstalled resolves EMPTY and dormant, never // an error // • a payload missing a `required` variable throws in dev, is dropped+logged // in prod // • engagement-triggers.json diffs zero, and is not a file that silently stops // checking // // …plus the property the org lead's §7.2 decision creates and the plan never had // to test before: **one namespace**. A trigger id and a stream id are the same // id, so the interesting cases are the same-owner upgrade (core's five, on every // boot) and the cross-owner collision (a module reaching for another's). // // Point the DB at a closed port BEFORE requiring anything: registries.js reaches // utils/discordAnnounce, which reaches the pool at require time. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, afterEach, after } = require('node:test') const assert = require('node:assert/strict') const registries = require('../src/modules/registries') const engagementEmit = require('../src/utils/engagementEmit') const ctrl = require('../src/router/v1/admin/engagement.controller') const coreTriggers = require('../src/config/coreTriggers') const db = require('../src/utils/db') after(() => db.close()) // Registries are process-global by design (there is one core), so a test that // registers has to be able to undo it. beforeEach(() => registries._reset()) afterEach(() => registries._reset()) // NODE_ENV decides throw-vs-drop, and node:test does not set it. Every emit test // states the posture it is testing rather than inheriting whatever the shell had. const originalEnv = process.env.NODE_ENV afterEach(() => { if (originalEnv === undefined) delete process.env.NODE_ENV else process.env.NODE_ENV = originalEnv }) function mockRes() { return { statusCode: 200, body: null, status(c) { this.statusCode = c; return this }, json(b) { this.body = b; return this }, } } /** A minimal valid declaration, for the tests that are about one field. */ const decl = (over = {}) => ({ id: 'uo.house.idoc_warning', label: 'House approaching collapse', ceiling: 'owner', variables: [ { name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }, { name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' }, ], ...over, }) /** Register a batch as `owner`, the way the loader's second pass commits one. */ function register(owner, fn) { const api = registries.stage(owner) fn(api) registries.apply(api.staged) } // ── Core's own declarations ──────────────────────────────────────────────── 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(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', () => { registries.registerCore() for (const id of ['team.member.joined', 'team.leadership.changed', 'team.forum.post', 'team.announcement']) { assert.equal(registries.eventTrigger(id).ceiling, 'members', id) } // News is public content, so it may reach every signed-in user — but its // DEFAULT is still the narrower `subscribers`, because a rule an operator has // not thought about should not be a newsletter to the whole site. const news = registries.eventTrigger('news.post') assert.equal(news.ceiling, 'authenticated') assert.equal(news.audience, 'subscribers') }) test('every core variable carries an example — the preview and test-send depend on it', () => { for (const t of coreTriggers.TRIGGERS) { for (const v of t.variables) { assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`) } } }) // ── One namespace (§7.2) ─────────────────────────────────────────────────── test('the same owner may hold an id as BOTH a stream and a trigger — that is the upgrade', () => { register('uo', (api) => { api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'Vendor sales' }]) api.registerEventTriggers([decl({ id: 'uo.market.sale', label: 'Vendor sale', ceiling: 'owner' })]) }) assert.equal(registries.isValidStream('uo.market.sale'), true) assert.equal(registries.eventTrigger('uo.market.sale').ceiling, 'owner') assert.equal(registries.eventOwner('uo.market.sale'), 'uo') }) test('a module cannot attach a payload contract to another owner\'s stream', () => { register('uo', (api) => { api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'Vendor sales' }]) }) assert.throws( () => register('rust', (api) => api.registerEventTriggers([decl({ id: 'uo.market.sale' })])), // The holder is named, and so is the facet it holds it as: under one // namespace "no such trigger" and "that id is someone's stream" are // different problems with the same symptom. /already registered as a notification stream by "uo"/, ) assert.equal(registries.eventTrigger('uo.market.sale'), null) }) test('and the collision is symmetric — a stream cannot take another owner\'s trigger id', () => { register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.market.sale' })])) assert.throws( () => register('rust', (api) => api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'x' }])), /already registered as an event trigger by "uo"/, ) }) test('a trigger must be namespaced under its owner, with the seven legacy ids exempt', () => { assert.throws( () => register('rust', (api) => api.registerEventTriggers([decl({ id: 'house.collapsed' })])), /not namespaced "rust\."/, ) // The same allowlist streams use, and it has to be the same one: under one // namespace `idoc.warning` is a single id, so if `uo` may hold it unprefixed // as a stream it may hold it unprefixed as a trigger. register('uo', (api) => api.registerEventTriggers([decl({ id: 'idoc.warning' })])) assert.equal(registries.eventTrigger('idoc.warning').owner, 'uo') }) test('an id with an underscore is legal — the grammar was relaxed, not replaced', () => { register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.house.idoc_warning' })])) assert.ok(registries.eventTrigger('uo.house.idoc_warning')) assert.throws( () => register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.House.Warning' })])), /bad trigger id/, ) }) test('nothing commits when a later claim in the same batch fails', () => { // A well-formed id that is not the owner's. Shape errors throw at the CALL // (checkTriggerShape, so the stack points at the module); this one survives to // apply(), which is where the all-or-nothing rule lives. assert.throws(() => register('uo', (api) => { api.registerEventTriggers([decl({ id: 'uo.a.one' }), decl({ id: 'other.thing' })]) }), /not namespaced/) assert.equal(registries.eventTrigger('uo.a.one'), null) }) // ── Declaration shape (§4.3) ─────────────────────────────────────────────── test('a ceiling is required and has no default — there is no safe value to guess', () => { assert.throws( () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: undefined })])), /needs a ceiling/, ) assert.throws( () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'everybody' })])), /needs a ceiling/, ) }) test('a default audience wider than the ceiling is refused at registration', () => { assert.throws( () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: 'everyone' })])), /is not permitted by ceiling "staff"/, ) // Incomparable is refused too, which is the case a total order would allow. assert.throws( () => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: 'owner' })])), /is not permitted by ceiling "staff"/, ) // Omitted, it defaults to the ceiling itself. register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: undefined })])) assert.equal(registries.eventTrigger('uo.house.idoc_warning').audience, 'staff') }) test('a variable without an example is refused — that is what makes preview possible', () => { assert.throws( () => register('uo', (api) => api.registerEventTriggers([ decl({ variables: [{ name: 'house', type: 'string', required: true }] }), ])), /needs an example/, ) }) test('a subjectKey naming no declared variable is refused', () => { assert.throws( () => register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'serial' })])), /subjectKey "serial" is not one of its variables/, ) register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'house' })])) assert.equal(registries.eventTrigger('uo.house.idoc_warning').subjectKey, 'house') }) test('kind defaults to event and only the two declared kinds are accepted', () => { register('uo', (api) => api.registerEventTriggers([ decl({ id: 'uo.a.one' }), decl({ id: 'uo.a.two', kind: 'scheduled' }), ])) assert.equal(registries.eventTrigger('uo.a.one').kind, 'event') assert.equal(registries.eventTrigger('uo.a.two').kind, 'scheduled') assert.throws( () => register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.a.three', kind: 'cron' })])), /unknown kind "cron"/, ) }) test('a declaration keeps only what the contract names', () => { register('uo', (api) => api.registerEventTriggers([decl({ handler: () => 'nope', secret: 'x' })])) const t = registries.eventTrigger('uo.house.idoc_warning') assert.equal(t.handler, undefined) assert.equal(t.secret, undefined) assert.deepEqual(Object.keys(t).sort(), [ 'audience', 'ceiling', 'description', 'id', 'kind', 'label', 'owner', 'subjectKey', 'variables', 'version', ]) }) // ── Audiences (§5.1a) ────────────────────────────────────────────────────── const aud = (over = {}) => ({ id: 'uo.team.members', label: 'Members of a team', ceiling: 'members', params: [{ id: 'teamId', type: 'int', required: true }], resolve: async () => [4, 9], ...over, }) test('an audience registers, resolves to user ids, and never leaks its resolver', async () => { register('uo', (api) => api.registerAudiences([aud()])) const listed = registries.allAudiences() assert.equal(listed.length, 1) assert.equal(listed[0].resolve, undefined) assert.deepEqual((await registries.resolveAudience('uo.team.members', { teamId: 3 })).userIds, [4, 9]) }) test('an audience whose module is uninstalled is DORMANT and empty, never an error', async () => { const gone = await registries.resolveAudience('uo.team.members', { teamId: 3 }) assert.deepEqual(gone, { dormant: true, userIds: [] }) }) test('a resolver that throws or answers rubbish costs an empty set, not a wrong one', async () => { register('uo', (api) => api.registerAudiences([ aud({ id: 'uo.a.boom', resolve: async () => { throw new Error('db down') } }), aud({ id: 'uo.a.junk', resolve: async () => 'everyone' }), aud({ id: 'uo.a.dirty', resolve: async () => [4, '9', 0, -2, 4, null, 'x'] }), ])) assert.deepEqual((await registries.resolveAudience('uo.a.boom')).userIds, []) assert.deepEqual((await registries.resolveAudience('uo.a.junk')).userIds, []) // Filtered to positive integers and de-duplicated. This is the one value a // module hands core that decides who receives mail. assert.deepEqual((await registries.resolveAudience('uo.a.dirty')).userIds, [4, 9]) // Not dormant: the module IS installed. Dormant is a different answer from // "resolved to nobody", and Phase 4's admin UI shows them differently. assert.equal((await registries.resolveAudience('uo.a.boom')).dormant, false) }) test('an audience needs a ceiling, a resolve, and its owner\'s prefix', () => { assert.throws(() => register('uo', (api) => api.registerAudiences([aud({ ceiling: undefined })])), /needs a ceiling/) assert.throws(() => register('uo', (api) => api.registerAudiences([aud({ resolve: undefined })])), /has no resolve\(\)/) assert.throws(() => register('rust', (api) => api.registerAudiences([aud()])), /not namespaced "rust\."/) }) test('audiences are their own id space — an audience may share a name with a trigger', () => { register('uo', (api) => { api.registerEventTriggers([decl({ id: 'uo.team.members' })]) api.registerAudiences([aud({ id: 'uo.team.members' })]) }) assert.ok(registries.eventTrigger('uo.team.members')) assert.ok(registries.audience('uo.team.members')) }) // ── The emit path (§4.3 property 1) ──────────────────────────────────────── const emitOk = () => { register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'house' })])) } test('a valid emit validates, normalises and returns the event', () => { process.env.NODE_ENV = 'development' emitOk() const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 'The Silver Anvil', nextStage: '2026-08-30T04:00:00Z' }, ownerUserId: 7, }) assert.equal(out.ok, true) assert.equal(out.event.subject, 'The Silver Anvil') // derived from subjectKey assert.equal(out.event.ownerUserId, 7) assert.equal(out.event.data.nextStage, '2026-08-30T04:00:00.000Z') // normalised assert.ok(out.event.occurredAt) }) test('a payload missing a required variable throws in dev and is dropped in prod', () => { emitOk() process.env.NODE_ENV = 'development' assert.throws( () => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { nextStage: '2026-08-30T04:00:00Z' } }), /house: required/, ) // Same call, production posture: no throw, and an unmistakable failure result. // This is called from inside a game-event handler; a contract problem of // core's must not become the module's control flow. process.env.NODE_ENV = 'production' const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: {} }) assert.equal(out.ok, false) assert.match(out.reason, /payload for "uo\.house\.idoc_warning" is invalid/) }) test('every payload problem is reported at once, not one per round trip', () => { process.env.NODE_ENV = 'development' register('uo', (api) => api.registerEventTriggers([decl({ variables: [ { name: 'house', type: 'string', required: true, example: 'x' }, { name: 'count', type: 'int', required: true, example: 2 }, { name: 'link', type: 'url', required: true, example: '/a' }, ], })])) assert.throws( () => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 5, count: 1.5, link: 'x' } }), /house: expected a string; count: expected an integer; link: expected a site-relative path/, ) }) test('a url variable is relative-only — a protocol-relative path never reaches an href', () => { process.env.NODE_ENV = 'production' register('uo', (api) => api.registerEventTriggers([decl({ variables: [{ name: 'link', type: 'url', required: true, example: '/houses/1' }], })])) const bad = (link) => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { link } }).ok assert.equal(bad('//evil.test/x'), false) assert.equal(bad('https://evil.test/x'), false) assert.equal(bad('houses/1'), false) assert.equal(bad('/houses/1?stage=2'), true) }) test('a module cannot emit another owner\'s trigger, nor an unknown one', () => { process.env.NODE_ENV = 'production' emitOk() const foreign = engagementEmit.emit('rust', 'uo.house.idoc_warning', { data: { house: 'x' } }) assert.equal(foreign.ok, false) assert.match(foreign.reason, /belongs to "uo"/) const unknown = engagementEmit.emit('uo', 'uo.nope.gone', { data: {} }) assert.equal(unknown.ok, false) assert.match(unknown.reason, /unknown event trigger/) }) test('a scheduled trigger is not emitted directly — the evaluator fires it (Q6)', () => { process.env.NODE_ENV = 'production' register('uo', (api) => api.registerEventTriggers([decl({ kind: 'scheduled' })])) const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 'x' } }) assert.equal(out.ok, false) assert.match(out.reason, /is kind "scheduled" and is not emitted directly/) }) test('an explicit subject beats the declared subjectKey; envelope fields are bounded', () => { process.env.NODE_ENV = 'production' emitOk() const call = (envelope) => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 'The Silver Anvil' }, ...envelope, }) assert.equal(call({ subject: 4141 }).event.subject, '4141') assert.equal(call({}).event.subject, 'The Silver Anvil') assert.equal(call({ subject: {} }).ok, false) assert.equal(call({ ownerUserId: 0 }).ok, false) assert.equal(call({ ownerUserId: '7' }).ok, false) assert.equal(call({ dedupeKey: 'x'.repeat(191) }).ok, false) assert.equal(call({ occurredAt: 'not a date' }).ok, false) assert.equal(call({ occurredAt: new Date('2026-01-02T03:04:05Z') }).event.occurredAt, '2026-01-02T03:04:05.000Z') }) test('undeclared payload keys are dropped rather than rejected', () => { process.env.NODE_ENV = 'development' emitOk() const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 'The Silver Anvil', ownerIp: '10.0.0.4' }, }) assert.equal(out.ok, true) assert.equal(out.event.data.ownerIp, undefined) }) // ── The admin catalog (G3) ───────────────────────────────────────────────── test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling vocabulary', () => { registries.registerCore() const res = mockRes() ctrl.listTriggers({}, res) // 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)) // The lattice travels with the catalog so the rule editor never offers an // audience the server will refuse. // `staff` permits itself and `admin` beneath it — the one refinement in the // tree (Phase 11). Every other branch permits itself alone. const staff = res.body.ceilings.find((c) => c.id === 'staff') assert.deepEqual(staff.permits, ['staff', 'admin']) const admin = res.body.ceilings.find((c) => c.id === 'admin') assert.deepEqual(admin.permits, ['admin']) const everyone = res.body.ceilings.find((c) => c.id === 'everyone') assert.equal(everyone.permits.length, 7) }) test('GET /admin/engagement/audiences never serves a resolver', () => { register('uo', (api) => api.registerAudiences([aud()])) const res = mockRes() ctrl.listAudiences({}, res) assert.equal(res.body.audiences.length, 1) assert.equal(res.body.audiences[0].resolve, undefined) assert.equal(res.body.audiences[0].ceiling, 'members') })