// 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: '

rendered

', 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: '

the actual private text

', }) 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: '

tonight

', }) 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 Team’s new posts into one mail', async () => { stubDigest({ posts: [ { id: 1, thread_id: 7, title: 'Raid', body_html: '

tonight

', author_username: 'ten' }, { id: 2, thread_id: 7, title: 'Raid', body_html: '

bring rope

', 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 rule’s digest template, not its instant one', async () => { stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '

x

', 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: '

x

', 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: '

x

', 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: '

x

', 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: '

x

', 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: '

x

', 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: '

x

', 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: '

x

', 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: '

x

', 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: '

x

', 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()) })