Files
website/server/test/engagementTriggers.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

476 lines
22 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('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')
})