Files
website/server/test/teamNotifyDispatch.test.js
wtclaude 065bec7ad8
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.

Seven decisions settled by the org lead before any code:

  - email only moves; the push tickle and the Discord bridge stay direct calls
  - the EVENT carries its access-checked audience, and `members` resolves to it
  - the four Team rules are seeded DISABLED, with an admin banner and a note
  - team_notification_prefs stays, read by the engine as a scoped preference
  - the payload wins and a structural projection fills the gaps
  - the digest keeps computing at send time; only its state generalizes
  - an unsubscribe token turns off the channel it names, and nothing else

Three defects found while building it:

  - `email.button` never absolutized its href, while image and itemList both
    did. Every rule-driven CTA would have been a dead relative link, because a
    trigger's url variables are validated site-relative by construction.
  - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
    not to build. An outbox row snapshots the payload and so has none of the
    three properties the digest design exists for, including the security one.
  - the digest's send-log row carried no address_hash while the instant row
    beside it did, which would have made half the mail uncorrelatable in Phase 9.

Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.

Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:11:54 -05:00

467 lines
23 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 Team notification fan-out and the digest worker (TEAMS.md §6.2/§6.4,
// migrated onto the engagement engine in ENGAGEMENT.md Phase 6).
//
// The layer above teamNotify.test.js: that one asserts WHO a recipient set
// contains, this one asserts what actually happens to them — which stream fires,
// what leaves for the engine, and the ways a notification is correctly suppressed.
//
// **What Phase 6 changed in this file, and what it deliberately did not.** The
// immediate email is no longer sent from here: `forumPost` emits an event and the
// engine decides. So the assertions about mail bodies moved down to the email
// channel's own tests, and what is asserted here is the ENVELOPE — the recipient
// set, the scope, the payload — because that is now the whole of this file's
// contract with the rest of the system. Every suppression test survives unchanged
// in intent, and each one is a refusal that would be invisible until it went
// wrong in production:
//
// • forums switched off silences forum notifications, including the digest;
// • no enabled rule means Team email is off (Phase 6, decision 3);
// • a Team's FIRST roster does not wake 155 phones;
// • a failed send does not stamp the digest window, so it is retried rather
// than silently skipped.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const notify = require('../src/utils/teamNotify')
const digest = require('../src/utils/teamDigestWorker')
const pushDispatch = require('../src/utils/pushDispatch')
const mailer = require('../src/utils/mailer')
const engagementEmit = require('../src/utils/engagementEmit')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
const notifyModel = require('../src/model/teams/teamNotify.model')
const digestDb = require('../src/model/engagement/engagementDigest.db')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const sendsDb = require('../src/model/engagement/engagementSends.db')
const templates = require('../src/engagement/templates')
const registries = require('../src/modules/registries')
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 TEAM = { id: 1, slug: 'silver-hand', name: 'The Silver Hand', external_id: 'g1', display_name_override: null }
// The rule the digest worker's gate looks for. Enabled and naming the email
// channel, which is exactly what core does NOT seed — see the gate's own test.
const EMAIL_RULE = {
id: 4,
trigger_id: 'team.forum.post',
enabled: 1,
channels: ['email'],
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
}
let sent // tickles
let emitted // envelopes handed to the engine
let mails // rendered messages handed to the transport
let world
function stub({
forumsEnabled = true,
emailConfigured = true,
recipients = [10, 11],
emailRows = [],
sendOk = true,
} = {}) {
sent = []
emitted = []
mails = []
world = { stamped: [], logged: [] }
patch(forumSettings, 'forumsEnabled', async () => forumsEnabled)
patch(mailer, 'isConfigured', async () => emailConfigured)
patch(mailer, 'sendNotification', async (msg) => {
mails.push(msg)
return sendOk ? { ok: true, transport: 'smtp' } : { ok: false, retry: true, detail: 'relay said no' }
})
patch(pushDispatch, 'publishToUsers', async (streamId, payload) => { sent.push({ streamId, ...payload }) })
patch(engagementEmit, 'emit', (owner, triggerId, envelope) => {
emitted.push({ owner, triggerId, ...envelope })
return { ok: true }
})
patch(notifyModel, 'recipientIds', async (teamId, { exclude = [] } = {}) =>
recipients.filter((id) => !exclude.includes(id)))
patch(notifyModel, 'emailRecipients', async (teamId, { exclude = [] } = {}) =>
emailRows.filter((r) => !exclude.includes(r.user_id)))
patch(digestDb, 'stampsFor', async () => new Map())
patch(digestDb, 'stamp', async (userId, channel, scopeKey, at) => {
world.stamped.push({ userId, channel, scopeKey, at })
})
patch(rulesDb, 'enabledForTrigger', async (triggerId) => (triggerId === EMAIL_RULE.trigger_id ? [EMAIL_RULE] : []))
patch(sendsDb, 'record', async (entry) => { world.logged.push(entry) })
patch(templates, 'renderByKey', async (key, values) => ({
subject: `[${key}] ${values.periodLabel || values.title || ''}`,
html: '<p>rendered</p>',
text: 'rendered',
missing: [],
values,
}))
// No module registered: the default in most tests, so the link-building ones
// have to opt in and the absence is exercised rather than assumed.
patch(registries, 'registeredTeamProvider', () => null)
}
beforeEach(() => stub())
afterEach(restore)
// ── Which stream fires ─────────────────────────────────────────────────────
test('a discussion reply fires team.forum.post; an announcement fires its own stream', async () => {
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
await notify.forumPost({ team: TEAM, threadId: 8, threadTitle: 'Notice', type: 'announcement', authorUserId: 10 })
assert.deepEqual(sent.map((s) => s.streamId), ['team.forum.post', 'team.announcement'])
// The trigger and the stream are the same id under §7.2's one namespace, so
// the event that leaves for the engine names the same thing the tickle did.
assert.deepEqual(emitted.map((e) => e.triggerId), ['team.forum.post', 'team.announcement'])
})
test('the tickle is content-free and refs the thread, never the body', async () => {
await notify.forumPost({
team: TEAM, threadId: 7, threadTitle: 'Secret plans', type: 'discussion', authorUserId: 99,
bodyHtml: '<p>the actual private text</p>',
})
assert.deepEqual(Object.keys(sent[0]).sort(), ['ref', 'streamId', 'userIds'])
assert.equal(sent[0].ref, 'team:1:thread:7')
assert.equal(JSON.stringify(sent[0]).includes('private text'), false)
})
test('the author is not among the tickled, nor among the emitted audience', async () => {
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.deepEqual(sent[0].userIds, [11])
// The same exclusion, on the same set: a forum that emails you your own post is
// the first thing anyone turns off.
assert.deepEqual(emitted[0].recipientUserIds, [11])
})
test('roster events fire one tickle for the run, not one per member', async () => {
await notify.memberJoined(TEAM)
await notify.leadershipChanged(TEAM)
assert.deepEqual(sent.map((s) => s.streamId), ['team.member.joined', 'team.leadership.changed'])
assert.equal(sent.every((s) => s.ref === 'team:1'), true)
})
// The tickle is per run and the EVENT is per person, and that split is the
// declaration's doing: `memberName` is a required single value, so five joiners
// cannot honestly be one event. The rule's cooldown is what stops five mails.
test('a roster sweep emits one event per joiner while tickling once', async () => {
await notify.memberJoined(TEAM, { count: 2, names: ['Darrow', 'Marisol'] })
assert.equal(sent.length, 1)
assert.deepEqual(emitted.map((e) => e.data.memberName), ['Darrow', 'Marisol'])
})
test('a leadership run with only demotions tickles but names no new leader', async () => {
await notify.leadershipChanged(TEAM, { names: [] })
assert.equal(sent.length, 1)
assert.equal(emitted.length, 0)
})
// ── What the envelope carries ──────────────────────────────────────────────
test('the event carries the access-checked audience, the scope and the team name', async () => {
await notify.forumPost({
team: TEAM, threadId: 7, threadTitle: 'Raid', type: 'discussion', authorUserId: 10,
authorName: 'Ten', bodyHtml: '<p>tonight</p>',
})
const event = emitted[0]
assert.equal(event.owner, 'core')
assert.deepEqual(event.recipientUserIds, [11])
// The SCOPE is the id, not the name: an unsubscribe token is signed over it and
// sits in a mailbox for months, and a Team renamed in between must not orphan
// the link. The name rides in the payload, where it is displayed and not keyed.
assert.equal(event.scopeKey, 'team:1')
assert.equal(event.data.teamName, 'The Silver Hand')
assert.equal(event.data.threadTitle, 'Raid')
assert.equal(event.data.excerpt, 'tonight')
})
test('an announcement names its title, a post names its thread title', async () => {
await notify.forumPost({ team: TEAM, threadId: 8, threadTitle: 'Notice', type: 'announcement', authorUserId: 10 })
// The two declarations differ here and a template can only name one of them —
// which is why the seeded announcement rule points at the generic body. Pinned
// so that reconciling the two declarations is a deliberate act with a version
// bump, not a silent rename that empties somebody's subject line.
assert.equal(emitted[0].data.title, 'Notice')
assert.equal(emitted[0].data.threadTitle, undefined)
})
test('the post url on the envelope is site-RELATIVE, as the emit contract requires', async () => {
patch(registries, 'registeredTeamProvider', () => ({ pageUrlTemplate: '/uo/guilds/{externalId}' }))
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
// An absolute one would be refused by `engagementEmit.RELATIVE_URL`, which
// exists so a `url` variable cannot carry a recipient off-site. The renderer
// absolutizes it against the deployment's base at send time.
assert.equal(emitted[0].data.postUrl, '/uo/guilds/g1?thread=7')
})
test('one post is one event however often the path re-runs', async () => {
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(emitted[0].dedupeKey, emitted[1].dedupeKey)
assert.match(emitted[0].dedupeKey, /^team:1:thread:7:/)
})
// ── Suppression ────────────────────────────────────────────────────────────
test('forums switched off silences a forum notification entirely', async () => {
stub({ forumsEnabled: false })
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
// `bridged` is phase 8's third sink (§7.2). Asserted as part of the shape
// rather than ignored: "forums are off" has to silence every sink, and a test
// that only checked two would not notice a third one still firing.
assert.deepEqual(res, { push: 0, emitted: false, bridged: false })
assert.equal(sent.length, 0)
assert.equal(emitted.length, 0)
})
test('a roster tickle survives forums being off — it is not forum content', async () => {
stub({ forumsEnabled: false })
await notify.memberJoined(TEAM)
assert.equal(sent.length, 1)
})
test('no recipients means no publish call and no event at all', async () => {
stub({ recipients: [] })
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(sent.length, 0)
// An event with an empty audience would enqueue nothing anyway; not emitting it
// keeps the send log and the emit log free of rows about nobody.
assert.equal(emitted.length, 0)
})
test('a fan-out never throws, whatever the layer below does', async () => {
patch(notifyModel, 'recipientIds', async () => { throw new Error('database is on fire') })
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.deepEqual(res, { push: 0, emitted: false, bridged: false })
assert.equal(await notify.memberJoined(TEAM), 0)
})
test('a refused emit is swallowed — a contract bug must not fail a forum write', async () => {
patch(engagementEmit, 'emit', () => { throw new Error('ctx.events.emit: payload is invalid') })
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(res.emitted, false)
assert.equal(res.push, 2 - 1) // the tickle still went out
})
// ── Links, and the module that supplies them ───────────────────────────────
test('with no module-supplied template there is no Team link, and nothing breaks', async () => {
assert.equal(notify.teamPageUrl(TEAM), null)
assert.equal(notify.threadUrl(TEAM, 7), null)
assert.equal(notify.teamPagePath(TEAM), null)
})
test('a registered template becomes an absolute link, and a thread deep-links by search param', () => {
patch(registries, 'registeredTeamProvider', () => ({ pageUrlTemplate: '/uo/guilds/{externalId}' }))
assert.match(notify.teamPageUrl(TEAM), /\/uo\/guilds\/g1$/)
assert.match(notify.threadUrl(TEAM, 7), /\/uo\/guilds\/g1\?thread=7$/)
// The path form is what travels on an envelope; the absolute form is what the
// Discord bridge posts, because a Discord client has no notion of this origin.
assert.equal(notify.teamPagePath(TEAM), '/uo/guilds/g1')
})
test('a Team is labelled by its display-name override where staff set one', () => {
assert.equal(notify.teamLabel(TEAM), 'The Silver Hand')
assert.equal(notify.teamLabel({ ...TEAM, display_name_override: 'Renamed' }), 'Renamed')
})
// ── The digest worker ──────────────────────────────────────────────────────
const DIGEST_ROW = { user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'digest' }
function stubDigest({ posts = [], teams = [TEAM], rows = [DIGEST_ROW], sendOk = true, stamps = new Map() } = {}) {
patch(notifyModel, 'teamsWithForumActivitySince', async () => teams)
patch(notifyModel, 'emailRecipients', async () => rows)
patch(notifyModel, 'digestPostsSince', async () => posts)
patch(digestDb, 'stampsFor', async () => stamps)
patch(mailer, 'sendNotification', async (msg) => {
mails.push(msg)
return sendOk ? { ok: true, transport: 'smtp' } : { ok: false, retry: true, detail: 'relay said no' }
})
}
test('a digest gathers the Teams new posts into one mail', async () => {
stubDigest({
posts: [
{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>tonight</p>', author_username: 'ten' },
{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>bring rope</p>', author_username: 'eleven' },
],
})
const res = await digest.tick(new Date())
assert.equal(res.sent, 1)
assert.equal(mails.length, 1)
assert.equal(mails[0].rendered.values.items.length, 2)
assert.equal(mails[0].to, 'e@example.test')
})
// §4.6.1: the digest's body is an operator-editable template, not a literal in
// `mailer`. The rule's `template_keys.digest` chooses it, and it is NOT
// `template_keys.email` — that one is written for a single event and would render
// a day of posts as one missing variable.
test('the digest renders the rules digest template, not its instant one', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
await digest.tick(new Date())
assert.match(mails[0].rendered.subject, /^\[notify\.digest\]/)
})
test('a recipient with nothing new gets no mail and no stamp', async () => {
stubDigest({ posts: [] })
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(mails.length, 0)
assert.equal(world.stamped.length, 0, 'stamping here would move the window past unsent posts')
})
test('a failed send leaves the digest window alone so it is retried', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }], sendOk: false })
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(world.stamped.length, 0)
// Failed, and recorded as failed. G15's question is "did user X get it?", and
// "no, and here is why" is an answer the send log has to be able to give.
assert.equal(world.logged[0].status, 'failed')
})
test('a successful send stamps exactly that (user, channel, scope)', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
const now = new Date()
await digest.tick(now)
// The stamp moved out of `team_notification_prefs.last_digest_at` and into
// `engagement_digest_state` (§4.2b), keyed by channel and scope so a second
// digest needs no second column on somebody's preferences row.
assert.deepEqual(world.stamped, [{ userId: 11, channel: 'email', scopeKey: 'team:1', at: now }])
assert.equal(world.logged[0].status, 'sent')
assert.equal(world.logged[0].outbox_id, null, 'a digest has no outbox row, and the null says so')
})
test('only digest-mode recipients are swept', async () => {
stubDigest({
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
rows: [
DIGEST_ROW,
{ user_id: 12, username: 'twelve', email: 'i@example.test', email_mode: 'immediate' },
{ user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off' },
],
})
await digest.tick(new Date())
assert.deepEqual(mails.map((m) => m.to), ['e@example.test'])
})
// The acceptance criterion, verbatim: a pre-existing `email_mode='digest'` row
// produces exactly one daily digest with the same window clamping. The stamp is
// the one the schema backfilled out of `team_notification_prefs`.
test('a pre-existing digest subscriber gets one digest, over the window their old stamp defines', async () => {
const now = new Date('2026-08-18T12:00:00Z')
const yesterday = new Date('2026-08-17T12:00:00Z')
let asked = null
stubDigest({
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
stamps: new Map([[11, yesterday]]),
})
patch(notifyModel, 'digestPostsSince', async (teamId, since) => {
asked = since
return [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }]
})
const res = await digest.tick(now)
assert.equal(res.sent, 1)
assert.equal(mails.length, 1)
assert.equal(asked.getTime(), yesterday.getTime())
})
// ── The three compute-at-send-time properties (§4.2b) ──────────────────────
//
// Each gets its own named test, the way the Teams phase-5 work gave the
// leader-can't-see-reports rule its own. They are the reason a digest is NOT
// assembled out of outbox rows, and a refactor that "unified" the two paths
// would break all three at once and pass every other test in this file.
test('property 1 — a two-day outage sends ONE digest, not two days of replay', async () => {
const now = new Date('2026-08-18T12:00:00Z')
stubDigest({
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
stamps: new Map([[11, new Date('2026-08-16T12:00:00Z')]]),
})
const res = await digest.tick(now)
assert.equal(res.sent, 1)
assert.equal(mails.length, 1)
})
test('property 2 — a post hidden after it was written is not in the query, so not in the mail', async () => {
// The moderator hid it, so `digestPostsSince` no longer returns it. There is
// nothing for the worker to remember to remove, which is the property: an
// outbox row snapshotted at publish time would still be carrying the text.
stubDigest({ posts: [] })
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(mails.length, 0)
})
test('property 3 — a user who lost access between the post and the send is not mailed', async () => {
// `emailRecipients` asks the same two tables the access resolver asks, at SEND
// time. A revoked guest is simply not in the answer. This is the one that would
// have been a security bug: the posts still exist and are still in the window.
stubDigest({
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
rows: [],
})
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(mails.length, 0)
})
// ── The gate (Phase 6, decision 3) ─────────────────────────────────────────
test('no enabled email rule means no digest — the operator has not turned Team email on', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
patch(rulesDb, 'enabledForTrigger', async () => [])
assert.equal((await digest.tick(new Date())).skipped, 'no-enabled-rule')
assert.equal(mails.length, 0)
})
test('a rule enabled on a channel other than email does not turn the digest on', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
patch(rulesDb, 'enabledForTrigger', async () => [{ ...EMAIL_RULE, channels: ['inapp'] }])
assert.equal((await digest.tick(new Date())).skipped, 'no-enabled-rule')
})
test('the sweep is skipped whole when forums are off or email is unconfigured', async () => {
stub({ forumsEnabled: false })
assert.equal((await digest.tick(new Date())).skipped, 'forums-disabled')
stub({ emailConfigured: false })
assert.equal((await digest.tick(new Date())).skipped, 'email-unconfigured')
})
test('the sweep never throws, and says so in its summary', async () => {
patch(notifyModel, 'teamsWithForumActivitySince', async () => { throw new Error('nope') })
assert.equal((await digest.tick(new Date())).skipped, 'error')
})
// ── The digest window ──────────────────────────────────────────────────────
test('a first digest reaches back one interval, not to the lookback floor', () => {
const now = new Date('2026-08-18T12:00:00Z')
const since = digest.clampSince(null, now)
assert.equal(now - since <= 24 * 60 * 60 * 1000, true)
})
test('a long outage is clamped: one digest, not a week of replay', () => {
const now = new Date('2026-08-18T12:00:00Z')
const ancient = new Date('2026-01-01T00:00:00Z')
const since = digest.clampSince(ancient, now)
assert.equal(now - since, digest.MAX_LOOKBACK_MS)
})
test('an ordinary last-send is used as-is', () => {
const now = new Date('2026-08-18T12:00:00Z')
const yesterday = new Date('2026-08-17T12:00:00Z')
assert.equal(digest.clampSince(yesterday, now).getTime(), yesterday.getTime())
})