// ── 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 suppressionsDb = require('../src/model/engagement/engagementSuppressions.db') const suppressions = require('../src/engagement/suppressions') const settings = require('../src/model/settings/settings.model') 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], ['suppressionsDb', suppressionsDb], ['settings', settings], ]) { 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(), // Phase 9: address hashes, and the verification gate's setting. suppressed: new Set(), verificationRequired: false, 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') // Phase 9: the reach preview now asks the email channel what it would actually // deliver, so the fake world has to be able to answer the two questions that // makes it ask - who is unverified, and who is suppressed. recipients.unverifiedAmong = async (ids) => new Set(ids.filter((id) => store.users.get(Number(id))?.email_verified === 0)) recipients.addressesFor = async (ids) => new Map( ids .filter((id) => store.users.get(Number(id))?.status === 'active') .map((id) => [Number(id), store.users.get(Number(id)).email]), ) suppressionsDb.suppressedAmong = async (hashes) => new Set(hashes.filter((h) => store.suppressed.has(h))) settings.isEmailVerificationRequired = async () => store.verificationRequired } // ── Fixtures ─────────────────────────────────────────────────────────────── const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', // Verified with an address by default: the reach preview counts deliverable // people, and a fixture that was unverified by accident would make every // count in this file read as zero. email: `u${id}@example.test`, email_verified: 1, ...over, }) const suppressUser = (id) => store.suppressed.add(suppressions.hashAddress(store.users.get(id).email)) 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) }) // Phase 9. `count` has never been how many people get a mail, and after this // phase there are two mechanisms that make the gap real. An operator reading // "3,000" beside a rule that will mail 1,796 people has been told something false // by the one screen whose entire job is that number. test('the preview says how many would actually be mailed, not just how many resolved', async () => { addUser(1) addUser(2) addUser(3) suppressUser(2) const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } }) assert.equal(res.body.count, 3) assert.equal(res.body.email.deliverable, 2) assert.equal(res.body.email.suppressed, 1) }) test('with the verification gate on, the preview counts the exclusion rather than hiding it', async () => { addUser(1) addUser(2, { email_verified: 0 }) store.verificationRequired = true const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } }) assert.equal(res.body.count, 2) assert.deepEqual(res.body.email.excluded, { unverified: 1 }) assert.equal(res.body.email.deliverable, 1) }) // The two mechanisms are asked in the order the engine applies them, so somebody // who is both unverified and suppressed is removed once. Counting them // independently would report more exclusions than there are people. test('a user who is both unverified and suppressed is not counted twice', async () => { addUser(1) addUser(2, { email_verified: 0 }) suppressUser(2) store.verificationRequired = true const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } }) assert.equal(res.body.email.deliverable, 1) assert.deepEqual(res.body.email.excluded, { unverified: 1 }) assert.equal(res.body.email.suppressed, 0, 'the gate already removed them') }) 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') })