Files
website/server/test/engagementAdmin.test.js
wtclaude 4b45eddb5d
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 10m36s
feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
The admin surface over the Phase 4a engine: two screens, twelve routes and the
reach preview. Nothing in the engine changed; what changed is that an operator
can now reach it.

Four decisions settled by the org lead before any code:

  - segments get their OWN nav entry, "Audiences", not a tab of the rules screen
  - the on/off switch is its own PATCH route, not a full PUT
  - the reach preview is a count only, on demand
  - a rule can be hard-deleted; the send log survives it

The switch is the one with real content in it. A PUT re-validates against the
registries as they are NOW, so the rules a re-validating toggle cannot switch
off are exactly the three an operator most wants stopped: a rule whose module
was uninstalled, one naming a channel that is gone, and one whose trigger has
since narrowed its ceiling under a saved audience. PATCH .../enabled writes one
column and always works. Switching ON unvalidated is safe because the engine
re-checks the ceiling at send time.

The preview calls the engine's own resolver rather than a second query that
agrees with it today, and answers a count and nothing else - the resolver's
output for a module-declared segment is a set of players derived from game data.
It reports `capped` at the 5000-row bound (the count is a floor, not a total),
`reason` for an `owner` audience (which resolves per event and has no advance
answer), and `permitted` so the editor cannot show a healthy number beside a
save the server will refuse.

Two defects found by walking it against a live server, both in Phase 4a's code:

  1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked
     only whether the segment ROW existed. The other shape of the same failure
     is a segment sitting exactly where it was whose every audience belongs to
     an uninstalled module: same outcome, nothing deleted. Uninstalling a module
     under an enabled rule produced a rule the screen showed as on and firing.
     The expression walk now lives in engagement/segments.js as
     `missingAudiences` and both lists ask it.
  2. "1 rule still use this segment" - the delete refusal pluralised the noun
     and not the verb, in the sentence an operator reads when told no.

Also: a rule's trigger is now a stated rule rather than an omission in the
UPDATE statement (its cooldowns, queued sends and history are all about one
trigger id); a condition tree the editor cannot render is shown read-only rather
than flattened, because flattening changes which events fire the rule; and
literals are coerced client-side to the type the trigger declared, with anything
that does not parse passed through unchanged so the server's refusal names the
variable.

Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones
(client/test/engagementRules.test.js), all green. The single failure in the
server suite (`the committed manifest matches the declarations in the tree`) is
the known Windows CRLF artifact and fails identically on clean edge.

Companion docs PR: docs#184.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:10:04 -05:00

470 lines
19 KiB
JavaScript

// ── The engagement admin surface (ENGAGEMENT.md Phase 4b) ──────────────────
//
// Phase 4a built the engine and the save-path validation with **no HTTP surface
// at all**; this is the surface, and these tests are about the things the routes
// decide rather than the things the model already decided. `engagementEngine`
// covers validation, ceilings and dormancy at the model layer — re-asserting
// them here would be a second copy of a test rather than a second test.
//
// What is genuinely new, and what each of these is about:
//
// • **the enable switch does not re-validate.** Turning a rule OFF is the panic
// button, and it has to work on the rule an operator most wants stopped — one
// whose module has been uninstalled, or whose trigger has since narrowed its
// ceiling under a saved audience. Those are exactly the rules a re-validating
// PUT refuses to save, so a toggle built on PUT is broken in precisely the
// case it is needed.
// • **the trigger is not updatable.** A rule's cooldowns, its pending outbox
// rows and its send-log history are all about one trigger id.
// • **deleting a segment a rule uses is 409, with the count**, because the
// database is deliberately not doing this (no foreign key: CASCADE deletes an
// operator's rules, SET NULL silently mails a different set of people).
// • **the reach preview is a count and never a list**, it says when it hit the
// 5000-row audience bound, and it says when the trigger's ceiling would
// refuse the audience it just counted.
//
// The `.db` layer is stubbed in-memory and the real models and controllers run
// against it, the shape `engagementEngine.test.js` uses.
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds 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 channels = require('../src/engagement/channels')
const ctrl = require('../src/router/v1/admin/engagement.controller')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const segmentsDb = require('../src/model/engagement/engagementSegments.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const db = require('../src/utils/db')
after(() => db.close())
// ── In-memory stand-ins for the two tables the surface writes ──────────────
let store
const originals = {}
for (const [name, mod] of [['rulesDb', rulesDb], ['segmentsDb', segmentsDb], ['recipients', recipients]]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
}
function installStubs() {
store = { rules: new Map(), segments: new Map(), users: new Map(), nextRule: 1, nextSegment: 1 }
rulesDb.list = async () => [...store.rules.values()].map((r) => ({ ...r }))
rulesDb.getById = async (id) => (store.rules.has(id) ? { ...store.rules.get(id) } : null)
rulesDb.insert = async (rule) => {
const id = store.nextRule++
store.rules.set(id, { id, ...rule })
return id
}
// Mirrors the real UPDATE statement, which does NOT carry trigger_id. That
// omission is the behaviour one of the tests below is about, so the stub has
// to reproduce it rather than helpfully assign the whole object.
rulesDb.update = async (id, rule) => {
const existing = store.rules.get(id)
if (!existing) return
const { trigger_id: _ignored, ...rest } = rule
Object.assign(existing, rest)
}
rulesDb.setEnabled = async (id, enabled, updatedBy) => {
const existing = store.rules.get(id)
if (existing) Object.assign(existing, { enabled: Boolean(enabled), updated_by: updatedBy })
}
rulesDb.remove = async (id) => store.rules.delete(id)
rulesDb.countUsingSegment = async (segmentId) =>
[...store.rules.values()].filter((r) => r.audience_segment_id === segmentId).length
segmentsDb.list = async () => [...store.segments.values()].map((s) => ({ ...s }))
segmentsDb.getById = async (id) => (store.segments.has(id) ? { ...store.segments.get(id) } : null)
segmentsDb.insert = async (segment) => {
const id = store.nextSegment++
store.segments.set(id, { id, ...segment })
return id
}
segmentsDb.update = async (id, segment) => Object.assign(store.segments.get(id) || {}, segment)
segmentsDb.remove = async (id) => store.segments.delete(id)
const activeIds = () => [...store.users.values()].filter((u) => u.status === 'active').map((u) => u.id)
recipients.active = async (limit = recipients.MAX_AUDIENCE) => activeIds().slice(0, limit)
recipients.staff = async (roles, limit = recipients.MAX_AUDIENCE) =>
[...store.users.values()]
.filter((u) => u.status === 'active' && roles.includes(u.role))
.map((u) => u.id)
.slice(0, limit)
recipients.subscribers = async () => []
recipients.filterActive = async (ids) =>
[...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active')
}
// ── Fixtures ───────────────────────────────────────────────────────────────
const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over })
function register(owner, fn) {
const api = registries.stage(owner)
fn(api)
registries.apply(api.staged)
}
const IDOC_TRIGGER = {
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
audience: 'owner',
subjectKey: 'house',
variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }],
}
const registerUoTrigger = (over = {}) =>
register('uo', (api) => api.registerEventTriggers([{ ...IDOC_TRIGGER, ...over }]))
function registerChannels() {
channels._reset()
delete require.cache[require.resolve('../src/engagement/coreChannels')]
// eslint-disable-next-line global-require
require('../src/engagement/coreChannels')
}
/** The controller signature is (req, res, next); this is the res half of it. */
function mockRes() {
return {
statusCode: 200,
body: null,
ended: false,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
end() { this.ended = true; return this },
}
}
/** Call a controller and fail the test on an unexpected throw, not silently. */
async function call(handler, req) {
const res = mockRes()
let thrown = null
await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => {
thrown = err
})
if (thrown) throw thrown
return res
}
const validRule = (over = {}) => ({
triggerId: 'uo.house.idoc_warning',
name: 'IDOC warning',
channels: ['email'],
...over,
})
beforeEach(() => {
registries._reset()
registerChannels()
installStubs()
registerUoTrigger()
})
afterEach(() => {
registries._reset()
restoreOriginals()
})
// ── Rules: create, list, update ────────────────────────────────────────────
test('a created rule arrives disabled unless it says otherwise', async () => {
const res = await call(ctrl.createRule, { body: validRule() })
assert.equal(res.statusCode, 201)
assert.equal(res.body.rule.enabled, false)
assert.equal(res.body.rule.trigger_id, 'uo.house.idoc_warning')
// §7.1 Q3: rules-as-data is only safe because of the hourly cap, so a rule
// that never mentions one still has one.
assert.equal(res.body.rule.max_sends_per_hour, 100)
})
test('an audience wider than the trigger permits is refused, and the reason is in errors[]', async () => {
const res = await call(ctrl.createRule, { body: validRule({ audience: 'everyone' }) })
assert.equal(res.statusCode, 400)
assert.ok(Array.isArray(res.body.errors) && res.body.errors.length)
assert.match(res.body.errors.join(' '), /wider than trigger/)
// `message` is the first sentence, for a toast; `errors` is the whole list,
// for a form putting each one beside its field.
assert.equal(res.body.message, res.body.errors[0])
})
test('the rules list flags a rule whose trigger is no longer registered, and does not drop it', async () => {
await call(ctrl.createRule, { body: validRule() })
registries._reset()
const res = await call(ctrl.listRules, {})
assert.equal(res.body.rules.length, 1)
assert.equal(res.body.rules[0].dormant, true)
assert.match(res.body.rules[0].dormantReasons.join(' '), /is not registered/)
})
test('updating a rule cannot re-point it at another trigger', async () => {
register('uo', (api) =>
api.registerEventTriggers([{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'Repaired' }]),
)
const created = await call(ctrl.createRule, { body: validRule() })
const id = created.body.rule.id
const res = await call(ctrl.updateRule, {
params: { id: String(id) },
body: { ...validRule({ triggerId: 'uo.house.repaired' }), name: 'renamed' },
})
assert.equal(res.statusCode, 200)
assert.equal(res.body.rule.name, 'renamed')
// A rule's cooldown rows, pending outbox rows and send-log history are all
// about one trigger. Re-pointing it would silently re-attribute all three.
assert.equal(res.body.rule.trigger_id, 'uo.house.idoc_warning')
})
// ── The enable switch: the property that made it its own route ─────────────
test('a rule whose module is gone can still be switched OFF', async () => {
const created = await call(ctrl.createRule, { body: validRule({ enabled: true }) })
const id = created.body.rule.id
// The module is uninstalled. This rule is now dormant, and it is also the rule
// an operator is most likely to want stopped.
registries._reset()
const res = await call(ctrl.setRuleEnabled, { params: { id: String(id) }, body: { enabled: false } })
assert.equal(res.statusCode, 200)
assert.equal(res.body.rule.enabled, false)
assert.equal(res.body.rule.dormant, true)
})
test('a full update of that same rule is refused — which is why the switch is not a PUT', async () => {
const created = await call(ctrl.createRule, { body: validRule({ enabled: true }) })
const id = created.body.rule.id
registerChannels()
channels._reset() // the module took its channel with it, too
const res = await call(ctrl.updateRule, { params: { id: String(id) }, body: validRule() })
assert.equal(res.statusCode, 400)
assert.match(res.body.errors.join(' '), /no channel "email" is registered/)
})
test('enabled must be a boolean, not a truthy string', async () => {
const created = await call(ctrl.createRule, { body: validRule() })
const res = await call(ctrl.setRuleEnabled, {
params: { id: String(created.body.rule.id) },
body: { enabled: 'false' },
})
assert.equal(res.statusCode, 400)
assert.equal(store.rules.get(created.body.rule.id).enabled, false)
})
test('toggling a rule that does not exist is 404, not a silent no-op', async () => {
const res = await call(ctrl.setRuleEnabled, { params: { id: '99' }, body: { enabled: false } })
assert.equal(res.statusCode, 404)
})
// ── Delete ─────────────────────────────────────────────────────────────────
test('deleting a rule answers 204 and removes it; deleting it twice is 404', async () => {
const created = await call(ctrl.createRule, { body: validRule() })
const id = String(created.body.rule.id)
const first = await call(ctrl.deleteRule, { params: { id } })
assert.equal(first.statusCode, 204)
assert.equal(store.rules.size, 0)
const second = await call(ctrl.deleteRule, { params: { id } })
assert.equal(second.statusCode, 404)
})
// ── Segments ───────────────────────────────────────────────────────────────
function registerAudiences() {
register('uo', (api) =>
api.registerAudiences([
{ id: 'uo.governors', label: 'Governors', ceiling: 'members', resolve: async () => [11, 12] },
{ id: 'uo.watchers', label: 'Watchers', ceiling: 'authenticated', resolve: async () => [10, 13] },
]),
)
}
test('a saved segment stores the DERIVED ceiling, never one the caller asked for', async () => {
registerAudiences()
const res = await call(ctrl.createSegment, {
body: {
name: 'Governors or watchers',
ceiling: 'everyone', // ignored: the ceiling is not the caller's to state
expression: { op: 'or', nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.watchers' }] },
},
})
assert.equal(res.statusCode, 201)
// members is below authenticated, so OR takes the TIGHTER of the two.
assert.equal(res.body.segment.ceiling, 'members')
})
test('a bare `not` is refused at save, with the sentence saying why', async () => {
registerAudiences()
const res = await call(ctrl.createSegment, {
body: { name: 'Everyone but governors', expression: { op: 'not', nodes: [{ audienceId: 'uo.governors' }] } },
})
assert.equal(res.statusCode, 400)
assert.match(res.body.errors.join(' '), /not/i)
})
test('deleting a segment a rule still uses is 409, and the count is in the message', async () => {
// A rule pointing at a `members` segment needs a trigger whose ceiling permits
// one, so this test re-registers the catalog rather than taking the default.
registries._reset()
registerUoTrigger({ ceiling: 'members', audience: 'members' })
registerAudiences()
const segment = await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
const segmentId = segment.body.segment.id
await call(ctrl.createRule, { body: validRule({ audienceSegmentId: segmentId }) })
const refused = await call(ctrl.deleteSegment, { params: { id: String(segmentId) } })
assert.equal(refused.statusCode, 409)
assert.match(refused.body.message, /1 rule still use|1 rule/)
assert.equal(store.segments.size, 1)
})
test('the same segment deletes once no rule points at it', async () => {
registerAudiences()
const segment = await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
const res = await call(ctrl.deleteSegment, { params: { id: String(segment.body.segment.id) } })
assert.equal(res.statusCode, 204)
assert.equal(store.segments.size, 0)
})
test('a rule whose segment still EXISTS but is dormant is itself dormant', async () => {
// The case a row-existence check misses, and the one the live walk found: the
// segment is still there, every audience in it belongs to a module that has
// been uninstalled, and the rule reaches nobody. Reported as healthy, it is an
// enabled rule that cannot fire and says nothing about it.
registries._reset()
registerUoTrigger({ ceiling: 'members', audience: 'members' })
registerAudiences()
const segment = await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
await call(ctrl.createRule, {
body: validRule({ audienceSegmentId: segment.body.segment.id, enabled: true }),
})
// The module goes; the segment ROW stays exactly where it was.
registries._reset()
registerUoTrigger()
registerChannels()
const res = await call(ctrl.listRules, {})
assert.equal(store.segments.size, 1, 'the segment row is still there')
assert.equal(res.body.rules[0].dormant, true)
assert.match(res.body.rules[0].dormantReasons.join(' '), /uo\.governors/)
})
test('a segment naming an audience whose module is gone is listed as dormant, not dropped', async () => {
registerAudiences()
await call(ctrl.createSegment, {
body: { name: 'Governors', expression: { audienceId: 'uo.governors' } },
})
registries._reset()
const res = await call(ctrl.listSegments, {})
assert.equal(res.body.segments.length, 1)
assert.equal(res.body.segments[0].dormant, true)
assert.deepEqual(res.body.segments[0].missingAudiences, ['uo.governors'])
})
// ── Reach preview ──────────────────────────────────────────────────────────
test('the preview counts, and returns no identities of any kind', async () => {
addUser(1, { role: 'admin' })
addUser(2, { role: 'moderator' })
addUser(3)
const res = await call(ctrl.previewAudience, { query: { audience: 'staff' } })
assert.equal(res.body.count, 2)
assert.equal(res.body.ceiling, 'staff')
// Whatever else this response grows, it must never grow a list of people: the
// resolver's answer for a module-declared segment is a set of players derived
// from game data, and the rule editor is not a user-enumeration surface.
const serialised = JSON.stringify(res.body)
assert.equal(serialised.includes('userIds'), false)
assert.equal(/"(users|names|ids|sample)"/.test(serialised), false)
})
test('a count that hit the audience bound says so, rather than reading as a total', async () => {
for (let id = 1; id <= recipients.MAX_AUDIENCE; id += 1) addUser(id)
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
assert.equal(res.body.count, recipients.MAX_AUDIENCE)
assert.equal(res.body.capped, true)
})
test('an `owner` audience previews as 0 with the reason, because it resolves per event', async () => {
addUser(1)
const res = await call(ctrl.previewAudience, {
query: { audience: 'owner', triggerId: 'uo.house.idoc_warning' },
})
assert.equal(res.body.count, 0)
assert.match(res.body.reason, /ownerUserId/)
assert.equal(res.body.permitted, true)
})
test('the preview reports when the trigger ceiling would refuse what it just counted', async () => {
addUser(1, { role: 'admin' })
const res = await call(ctrl.previewAudience, {
query: { audience: 'staff', triggerId: 'uo.house.idoc_warning' },
})
// The count is real — those people exist — but this trigger is ceilinged
// `owner`, so saving a rule with it would be refused. Showing a healthy number
// with no other signal reads as a bug in the save.
assert.equal(res.body.count, 1)
assert.equal(res.body.permitted, false)
})
test('an audience name the lattice does not know is 400, not an empty count', async () => {
const res = await call(ctrl.previewAudience, { query: { audience: 'admins' } })
assert.equal(res.statusCode, 400)
})
// ── The catalog's third leg ────────────────────────────────────────────────
test('the channel catalog is served from the registry, defaults included', async () => {
const res = await call(ctrl.listChannels, {})
const email = res.body.channels.find((c) => c.id === 'email')
assert.ok(email, 'core registers an email channel')
// §7.1 Q1 / §3.1: every channel is opt-IN. The editor has to be able to say so.
assert.equal(email.defaultMode, 'off')
})