Files
website/server/test/engagementAdmin.test.js
wtclaude c208543044
All checks were successful
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 5m12s
feat(engagement): deliverability — suppression, bounces and the verification gate
ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.

`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.

The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.

`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.

Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.

Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.

`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.

- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
  → Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated

Docs: RunicGateway/docs#191.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 10:47:34 -05:00

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