Files
website/server/test/engagementTriggers.test.js
wtclaude 1d4cd4adae
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m3s
feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled
before any code that land in core rather than in module-uo. Pairs with
Module-uo#22 and docs#194.

## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff`

Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone,
uo.world.saved) are described as admin-audience everywhere, and the narrowest
value the lattice had was `staff` -- which ceilings.js defines as admin, editor
AND moderator. Ceilinging them there would have let an operator save a rule that
mails the staff audit digest to every moderator in it.

`admin` is the ONLY genuine refinement in the tree -- every admin is staff, which
is exactly the containment every other pair of branches lacks -- so it is a child
rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the
new PARENT entry.

**The one non-obvious consequence, and the reason for ROLE_CEILINGS.**
notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was
correct while `staff` was the only role-gated value, and the day `admin` arrived
it would have silently published every admin-ceilinged id -- the staff audit
digest, the economy thresholds -- to every player's preferences screen by name.
It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry
fails closed instead. An EDITOR is the viewer that tells the two rules apart, and
the new tests use one.

MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid
under 1.7.0 is valid now and no stored value changes.

## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle

`news.post` has been a declared payload contract with no caller since Phase 2, so
a rule naming it could never fire. utils/newsNotify.js is the caller;
announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on
the same enqueueIfNeeded job id -- the single "newly published news" transition
signal, not re-derived.

**News push therefore stops on upgrade** until an operator enables the seeded
rule. That is the org lead's decision, taken over keeping the raw call beside the
emit "for one release": an exception with a deadline nobody owns, which Phase 6
already refused for Teams. The Rules screen gains a second migration notice
naming news, and Phase 13's release note carries it as an upgrade step.

**The seed needed its own one-shot key, and this is the trap worth recording.**
`engagement_team_rules_seeded` is already stamped on every deployment that has
booted since Phase 6, and the guard reads its presence -- so appending news to
RULES would have seeded it on fresh installs only, and on exactly the upgrades
that lose their raw push, never. One key per seed GROUP is now the rule;
seedGroup() is the shared implementation and seedCoreRules() is what boot calls.

Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path
App.jsx does not mount. An example is what the template editor previews and
test-sends with, so a wrong one is a preview that looks right and a mail that is
not. It is `/site/news`, the list, which is what the Discord and town-crier
announcements have always linked.

1550 tests pass (16 new), 327 client tests pass, client builds, check:modules
clean -- core still names no module identifier with module-uo now registering 24
UO-named triggers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:33:02 -05:00

440 lines
20 KiB
JavaScript

// ── 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('core registers its five triggers, and they are the five stream ids', () => {
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(triggerIds, [
'news.post', 'team.announcement', 'team.forum.post',
'team.leadership.changed', 'team.member.joined',
])
})
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)
assert.equal(res.body.triggers.length, 5)
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')
})