Files
website/server/test/engagementEmail.test.js
wtclaude eec7dbf785
All checks were successful
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 5m29s
PR Checks / bot-tests (pull_request) Successful in 8m30s
fix(engagement): claim the seed guard atomically, and let a rule name a digest body
Both defects came out of Phase 13's acceptance walk, and neither was visible to
any test.

**1. The one-shot rule-group guard was not a guard.** `seedRuleGroup` and
`coreRules.seedGroup` both read their settings stamp, inserted the whole group,
and wrote the stamp AFTER the loop. Two instances booting in the same moment both
read "absent" and both insert -- the walk ended up with 52 module rules where the
module ships 26. `docker compose up --scale app=2` and a rolling restart both
start two instances on purpose, so this is ordinary rather than exotic.

`settings.db` gains `claim(key, value)`: the same `INSERT IGNORE` as
`seedDefault`, reporting its own `affectedRows`, so exactly one caller can win a
key. The atomicity is the PRIMARY KEY's -- no transaction, no lock, the same
bargain `engagementWorker`'s row claim already makes. Both seeders claim before
inserting.

The trade the code already documented is unchanged, only its order: a process
that dies mid-loop leaves the group stamped and partly seeded, and the missing
rules are an operator's visit to the "new rule" form. A duplicate rule is two
mails per event, for every rule in the group, which both functions' own comments
already call the worse outcome.

**Why no test caught it:** the stubs supplied `get` and `set` over a Map, which
cannot race, so they agreed with the bug -- Phase 4a's `foundRows` finding in a
different costume. The fake is now `claim`-shaped and decides without yielding,
exactly as the table does, and both suites gained a test that runs two seeders
with `Promise.all` and asserts one insert each. Verified by reverting the fix:
the module test then reports every rule inserted twice.

**2. A rule that names a `digest` body could not be saved.** `registries.js`
`checkSeedRule` permits `digest` in as many words -- it is the body
`teamDigestWorker` renders for a rule whose email channel an individual set to
digest mode, so it never appears in `channels` and never could -- and
MODULE_API.md 2.4 tells modules they may point `template_keys` at
`notify.digest`. `engagementRules.model.js` then rejected any key that was not
one of the rule's channels.

So every rule shipping a digest body answered an operator who opened it and
pressed Save with a 400 naming a key they had never typed: core's own Team and
news rules, and sixteen of module-uo's. The only way to save was to delete the
digest body, silently dropping digest support from that rule. The two validators
now agree; anything that is neither a channel nor `digest` is still refused, with
a test for each direction.

No MODULE_API bump: no member is added, removed or changed, and the documented
contract is what the code now honours rather than something new.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 14:29:21 -05:00

431 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The email channel on the engine (ENGAGEMENT.md Phase 6) ────────────────
//
// The phase's own acceptance criteria, plus the four things building it showed
// were worth pinning:
//
// • a scoped preference is what decides a Team-scoped event, and it REPLACES
// the stream-level one — intersecting would silence every existing subscriber
// • the structural projection fills only what the payload did not
// • a `members` audience resolves to the set the EVENT carried
// • core's four seeded rules exist, are all disabled, and are seeded once
//
// 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 scopedPrefs = require('../src/engagement/scopedPrefs')
const projection = require('../src/engagement/projection')
const audiences = require('../src/engagement/audiences')
const engine = require('../src/engagement/engine')
const emailChannel = require('../src/engagement/emailChannel')
const coreRules = require('../src/engagement/coreRules')
const templates = require('../src/engagement/templates')
const mailer = require('../src/utils/mailer')
const unsubscribeToken = require('../src/utils/unsubscribeToken')
const engagementEmit = require('../src/utils/engagementEmit')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const settingsDb = require('../src/model/settings/settings.db')
const teamNotifyModel = require('../src/model/teams/teamNotify.model')
const unsubCtrl = require('../src/router/v1/public/engagement.controller')
const db = require('../src/utils/db')
// Requiring this is what registers core's channels, transports and the `team`
// scope provider — the one door (engagement/index.js's header). It does NOT
// register core's TRIGGERS: those come from `registries.registerCore()`, which
// app.js calls, and the split is deliberate — a trigger is a module-facing
// declaration and a channel is an internal sink.
require('../src/engagement')
registries.registerCore()
after(() => db.close())
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
const TRIGGER = 'team.forum.post'
let world
beforeEach(() => {
world = { mails: [], teamPrefs: new Map(), storedModes: new Map(), inserted: [], settings: new Map() }
patch(mailer, 'sendNotification', async (msg) => {
world.mails.push(msg)
return { ok: true, transport: 'smtp' }
})
patch(templates, 'renderByKey', async (key, values) => ({
subject: `[${key}] ${values.title || ''}`,
html: '<p>x</p>',
text: 'x',
missing: [],
values,
}))
patch(recipients, 'addressFor', async (userId) => ({ address: `u${userId}@example.test` }))
patch(recipients, 'filterActive', async (ids) => ids)
patch(recipients, 'storedModes', async () => world.storedModes)
patch(teamNotifyModel, 'prefsForTeam', async (userIds, teamId) =>
userIds
.map((id) => world.teamPrefs.get(`${id}|${teamId}`))
.filter(Boolean))
patch(rulesDb, 'getById', async () => ({
id: 1,
trigger_id: TRIGGER,
template_keys: { email: 'notify.team-post' },
}))
})
afterEach(restore)
const outboxRow = (over = {}) => ({
id: 1,
rule_id: 1,
trigger_id: TRIGGER,
user_id: 11,
channel: 'email',
subject_key: 'The Silver Hand',
scope_key: 'team:1',
payload: { teamName: 'The Silver Hand', authorName: 'Ten', threadTitle: 'Raid', postUrl: '/g/1?thread=7' },
...over,
})
// ── deliver ────────────────────────────────────────────────────────────────
test('a delivered row renders its rules template and sends to the users address', async () => {
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, true)
assert.equal(world.mails[0].to, 'u11@example.test')
assert.match(world.mails[0].rendered.subject, /^\[notify\.team-post\]/)
})
test('a rule naming no template falls back to the generic one, which is what makes a new trigger mailable', async () => {
patch(rulesDb, 'getById', async () => ({ id: 1, trigger_id: TRIGGER, template_keys: {} }))
await emailChannel.deliver(outboxRow())
assert.match(world.mails[0].rendered.subject, /^\[notify\.event\]/)
})
// Terminal, not retryable. Retrying does not give somebody an address, and a
// banned account will not be un-banned by a five-minute backoff.
test('a user with no deliverable address is a terminal failure, not a retry', async () => {
patch(recipients, 'addressFor', async () => null)
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.equal(result.retry, undefined)
assert.equal(world.mails.length, 0)
})
// A throw would be read by the worker as a transient failure and retried five
// times, so an unrenderable template would become five identical rows in the send
// log instead of one honest terminal one.
test('deliver never throws — a render failure is classified, not propagated', async () => {
patch(templates, 'renderByKey', async () => { throw new Error('blocks are broken') })
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.match(result.detail, /blocks are broken/)
})
test('the send log gets a hash of the address and never the address', async () => {
const result = await emailChannel.deliver(outboxRow())
assert.match(result.addressHash, /^[0-9a-f]{64}$/)
assert.equal(result.addressHash.includes('@'), false)
})
// A bounce arrives with an address, not with a spelling. Two spellings of one
// mailbox must hash to one row or the correlation Phase 9 needs cannot be made.
test('the address hash is case-folded, so a bounce can be correlated', async () => {
const a = await emailChannel.deliver(outboxRow())
patch(recipients, 'addressFor', async () => ({ address: ' U11@Example.Test ' }))
const b = await emailChannel.deliver(outboxRow())
assert.equal(a.addressHash, b.addressHash)
})
// ── The unsubscribe link ───────────────────────────────────────────────────
test('every mail carries both unsubscribe urls, and they are not the same url', async () => {
await emailChannel.deliver(outboxRow())
const { unsubscribeUrl, unsubscribeApiUrl } = world.mails[0]
assert.notEqual(unsubscribeUrl, unsubscribeApiUrl)
// The header one has to be an ENDPOINT — a one-click client POSTs to it without
// rendering anything — and the body one has to be a page a human can read first.
assert.match(unsubscribeApiUrl, /\/api\/v1\/public\/engagement\/unsubscribe\//)
assert.match(unsubscribeUrl, /\/unsubscribe\//)
})
test('the links token names the email channel and the events SCOPE, not its subject', async () => {
await emailChannel.deliver(outboxRow())
const token = world.mails[0].unsubscribeApiUrl.split('/').pop()
assert.deepEqual(unsubscribeToken.verify(token), {
userId: 11, channel: 'email', scopeKey: 'team:1', version: 2,
})
// `subject_key` is the Team's NAME, which a rename changes. Signing over it
// would orphan every link in a mailbox the first time staff renamed a guild.
assert.equal(token.includes('Silver'), false)
})
test('a scope the token format cannot carry costs the link, not the mail', async () => {
const result = await emailChannel.deliver(outboxRow({ scope_key: 'NOT A SCOPE' }))
assert.equal(result.ok, true)
assert.equal(world.mails[0].unsubscribeUrl, null)
})
// ── The projection (§4.6.1 property 1) ─────────────────────────────────────
test('the projection fills only what the payload left out', () => {
const declaration = registries.eventTrigger(TRIGGER)
assert.ok(declaration, 'core registers the four Team triggers')
const values = projection.project(TRIGGER, { teamName: 'X', threadTitle: 'Raid', postUrl: '/g/1' })
assert.equal(values.threadTitle, 'Raid') // untouched
assert.equal(values.title, declaration.label) // supplied
assert.equal(values.intro, declaration.description)
assert.equal(values.actionUrl, '/g/1') // the first declared url with a value
})
// `news.post` and `team.announcement` both declare their own `title`. A
// projection that overwrote it would replace a real headline with a category
// label — on the one variable every generic template puts in the subject line.
test('a payload that declares its own title keeps it', () => {
const values = projection.project('team.announcement', { teamName: 'X', title: 'Siege moved' })
assert.equal(values.title, 'Siege moved')
})
test('an unregistered trigger still renders from its snapshot rather than being refused', () => {
const values = projection.project('gone.away', { title: 'kept' })
assert.equal(values.title, 'kept')
assert.deepEqual(values.items, [])
})
// ── The event-carried audience (decision 2) ────────────────────────────────
test('a members audience resolves to the recipient set the event carried', async () => {
const resolved = await audiences.resolveForRule(
{ audience: 'members', audience_segment_id: null },
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
)
assert.deepEqual(resolved.userIds, [11, 12])
assert.equal(resolved.ceiling, 'members')
assert.equal(resolved.dormant, false)
})
test('a members audience with no carried set and no segment still reaches nobody', async () => {
const resolved = await audiences.resolveForRule(
{ audience: 'members', audience_segment_id: null },
{ triggerId: TRIGGER },
)
assert.deepEqual(resolved.userIds, [])
assert.match(resolved.reason, /needs a segment/)
})
// The carried set is a NARROWING input. It names who the event is about; it does
// not raise what a rule is allowed to reach.
test('a carried audience is still filtered for account status', async () => {
patch(recipients, 'filterActive', async (ids) => ids.filter((id) => id !== 12))
const resolved = await audiences.resolveForRule(
{ audience: 'members', audience_segment_id: null },
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
)
assert.deepEqual(resolved.userIds, [11])
})
test('and it is still under the triggers G24 ceiling', () => {
// `members` is what the four Team triggers ceiling at, so a carried set can
// never be given `authenticated` by a rule that names one.
assert.equal(audiences.permitted(TRIGGER, 'members'), true)
assert.equal(audiences.permitted(TRIGGER, 'authenticated'), false)
})
test('the emit contract refuses an audience that is not a list of user ids', () => {
const bad = (recipientUserIds) =>
assert.throws(() => engagementEmit.emit('core', TRIGGER, {
data: { teamName: 'X', authorName: 'A', threadTitle: 'T' },
recipientUserIds,
}))
bad('11')
bad([0])
bad([1.5])
bad(new Array(6000).fill(1).map((_, i) => i + 1))
})
// ── Scoped preferences (decision 4) ────────────────────────────────────────
const teamPref = (userId, teamId, over) => world.teamPrefs.set(`${userId}|${teamId}`, {
user_id: userId, muted: 0, email_mode: 'off', ...over,
})
test('a Team-scoped email event is decided by team_notification_prefs', async () => {
teamPref(11, 1, { email_mode: 'immediate' })
teamPref(12, 1, { email_mode: 'off' })
const eligible = await engine.subscribedTo([11, 12], TRIGGER, 'email', 'team:1')
assert.deepEqual(eligible, [11])
})
// **The heart of decision 4.** `notification_channel_prefs` holds a row only
// where a user expressed something, absence means the channel default, and
// email's is `off`. Nobody has ever expressed a stream-level opinion about a Team
// trigger — the screen that would let them is Phase 3's and the preference
// predates it. So intersecting the two would resolve every existing Team-email
// subscriber to `off` and silence the live pipeline on the migrating deploy.
test('the scoped preference REPLACES the stream one — it does not intersect with it', async () => {
teamPref(11, 1, { email_mode: 'immediate' })
world.storedModes = new Map() // no stream-level row: the state of every real user
assert.equal(channels.defaultMode('email'), 'off')
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
})
test('a per-Team mute silences every channel, not only the one that carries content', async () => {
teamPref(11, 1, { muted: 1, email_mode: 'immediate' })
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [])
world.storedModes = new Map([[11, 'instant']])
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [])
})
test('a scope says nothing about push, so the stream preference decides it', async () => {
teamPref(11, 1, { email_mode: 'off' })
world.storedModes = new Map([[11, 'instant']])
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [11])
})
test('an unscoped event is decided by the stream preference alone', async () => {
teamPref(11, 1, { email_mode: 'immediate' })
world.storedModes = new Map()
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', null), [])
})
// Fails OPEN, and the practical effect is that nothing is sent rather than that
// everybody is: the stream-level default is `off`. Failing closed would instead
// drop an unrelated IDOC warning because a Team preference query timed out.
test('a scope provider that throws leaves the stream preference in charge', async () => {
patch(teamNotifyModel, 'prefsForTeam', async () => { throw new Error('db is on fire') })
world.storedModes = new Map([[11, 'instant']])
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
})
test('an unparseable or unclaimed scope is "no scope", never some other scope', async () => {
assert.equal(scopedPrefs.parse('team:1').prefix, 'team')
assert.equal(scopedPrefs.parse('team:'), null)
assert.equal(scopedPrefs.parse(':1'), null)
assert.equal(scopedPrefs.parse(''), null)
assert.equal((await scopedPrefs.resolve([11], 'email', 'nosuch:1')).size, 0)
})
// ── The one-click unsubscribe (decision 7) ─────────────────────────────────
test('a v2 email token turns off that Teams email and leaves its push alone', async () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(['email', ...a]))
patch(teamNotifyModel, 'mute', async (...a) => writes.push(['mute', ...a]))
await unsubCtrl.applyClaim({ userId: 11, channel: 'email', scopeKey: 'team:1' })
assert.deepEqual(writes, [['email', 11, 1, 'off']])
})
// The acceptance criterion: a link from a mail sent BEFORE the migration still
// works. It arrives at the old path, verifies as a v1 token, and turns off the
// email it was labelled as turning off.
test('a pre-migration link still unsubscribes, through the same handler', async () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
const legacy = unsubscribeToken.signLegacy(11, 1)
const res = { json: (body) => { res.body = body } }
await unsubCtrl.unsubscribe({ params: { token: legacy } }, res)
assert.deepEqual(res.body, { ok: true })
assert.deepEqual(writes, [[11, 1, 'off']])
})
// An oracle for which (user, scope) pairs exist would be a real disclosure on an
// endpoint with no session behind it.
test('a forged token is answered exactly like a real one', async () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
const res = { json: (body) => { res.body = body } }
await unsubCtrl.unsubscribe({ params: { token: '2.11.email.team:1.AAAAAAAAAAAAAAAAAAAAAA' } }, res)
assert.deepEqual(res.body, { ok: true })
assert.deepEqual(writes, [])
})
test('a GET on the unsubscribe endpoint mutates nothing and lands on the page', () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
let redirected = null
unsubCtrl.unsubscribeLanding(
{ params: { token: unsubscribeToken.sign(11, 'email', 'team:1') } },
{ redirect: (code, url) => { redirected = { code, url } } },
)
assert.equal(redirected.code, 302)
assert.match(redirected.url, /\/unsubscribe\//)
assert.deepEqual(writes, [], 'a link scanner must not be able to unsubscribe anybody')
})
// ── The seeded rules (decision 3) ──────────────────────────────────────────
test('core seeds a rule for each Team trigger, and every one of them is OFF', async () => {
patch(settingsDb, 'claim', async (k, v) => { world.settings.set(k, v); return true })
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
const summary = await coreRules.seedTeamRules()
assert.equal(summary.inserted, 4)
assert.deepEqual(
world.inserted.map((r) => r.trigger_id).sort(),
['team.announcement', 'team.forum.post', 'team.leadership.changed', 'team.member.joined'],
)
// The invariant the org lead chose to honour rather than carve an exception
// into: nothing is seeded on. Team email resumes when an operator switches one
// on, and the release note says so.
assert.equal(world.inserted.every((r) => r.enabled === 0), true)
// `members`, which is what resolves to the carried recipient set. Anything
// wider would be refused by the trigger's own ceiling anyway.
assert.equal(world.inserted.every((r) => r.audience === 'members'), true)
assert.equal(world.inserted.every((r) => r.channels.includes('email')), true)
})
test('every seeded rule names a template that actually exists', () => {
const seeded = new Set(coreRules.RULES.flatMap((r) => Object.values(r.template_keys)))
const shipped = new Set(require('../src/engagement/templateSeeds').SEEDS.map((s) => s.key))
for (const key of seeded) assert.equal(shipped.has(key), true, `${key} is not a shipped template`)
})
// Seeded once, not ensured: an operator who deletes a rule must not find it back
// after a restart, and one they enabled must not be reset to off.
test('a second boot seeds nothing', async () => {
// The guard is a CLAIM, so "already seeded" is the claim losing rather than a
// read finding a row. It is the same one-shot promise, made atomically: two
// instances booting together used to both read "absent" and both seed, and a
// duplicate rule is two mails per event (Phase 13's acceptance walk).
patch(settingsDb, 'claim', async () => false)
patch(rulesDb, 'insert', async () => { throw new Error('must not insert') })
const summary = await coreRules.seedTeamRules()
assert.equal(summary.inserted, 0)
assert.equal(summary.skipped, 4)
})
test('two instances booting together seed the Team rules once, not twice', async () => {
// Not awaited in turn on purpose: interleaving the two calls is the test, and
// awaiting the first would pass against the read-then-write guard this
// replaced. `claim` decides and writes without yielding, exactly as the
// settings table's PRIMARY KEY does.
const claimed = new Set()
patch(settingsDb, 'claim', async (k) => {
if (claimed.has(k)) return false
claimed.add(k)
return true
})
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
const [a, b] = await Promise.all([coreRules.seedTeamRules(), coreRules.seedTeamRules()])
assert.equal(a.inserted + b.inserted, 4)
assert.equal(world.inserted.length, 4)
})