// The Team notification fan-out and the digest worker (TEAMS.md §6.2/§6.4). // // 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 a mail carries, and the four ways a notification is correctly suppressed. // // The suppressions are the point. A notification feature is mostly refusals, and // each of these is one that would be invisible until it went wrong in production: // // • forums switched off silences forum notifications, including the digest; // • no email configured means the sink is absent, not broken; // • a Team's FIRST roster does not wake 155 phones; // • a failed send does not stamp `last_digest_at`, so the window 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 forumSettings = require('../src/model/teams/teamForumSettings.model') const notifyModel = require('../src/model/teams/teamNotify.model') 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 } let sent // tickles let mails // emails let world function stub({ forumsEnabled = true, emailConfigured = true, recipients = [10, 11], emailRows = [] } = {}) { sent = [] mails = [] world = { stamped: [] } patch(forumSettings, 'forumsEnabled', async () => forumsEnabled) patch(mailer, 'isConfigured', async () => emailConfigured) patch(mailer, 'sendTeamNotification', async (msg) => { mails.push(msg) return { sent: true } }) patch(pushDispatch, 'publishToUsers', async (streamId, payload) => { sent.push({ streamId, ...payload }) }) 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(notifyModel, 'stampDigest', async (userId, teamId, at) => { world.stamped.push({ userId, teamId, at }) }) // 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']) }) 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', async () => { await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 }) assert.deepEqual(sent[0].userIds, [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) }) // ── 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, emails: 0, bridged: false }) assert.equal(sent.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 at all', async () => { stub({ recipients: [] }) await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 }) assert.equal(sent.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, emails: 0, bridged: false }) assert.equal(await notify.memberJoined(TEAM), 0) }) // ── Email, the immediate mode ────────────────────────────────────────────── const IMMEDIATE = [{ user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'immediate' }] test('only the immediate-mode recipients are emailed per event', async () => { stub({ emailRows: [ ...IMMEDIATE, { user_id: 12, username: 'twelve', email: 'd@example.test', email_mode: 'digest' }, { user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off' }, ], }) await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10, bodyHtml: 'hello
' }) assert.deepEqual(mails.map((m) => m.to), ['e@example.test']) }) test('an email carries an excerpt and never the whole post', async () => { stub({ emailRows: IMMEDIATE }) const long = `${'x'.repeat(500)}
` await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10, bodyHtml: long }) const body = mails[0].items[0].excerpt assert.equal(body.length < 250, true) assert.equal(body.endsWith('…'), true) }) test('no email configured means no send and no recipient query', async () => { stub({ emailConfigured: false, emailRows: IMMEDIATE }) const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 }) assert.equal(res.emails, 0) assert.equal(mails.length, 0) }) test('every email carries an unsubscribe url for that recipient and that Team', async () => { stub({ emailRows: IMMEDIATE }) await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 }) assert.equal(typeof mails[0].unsubscribeUrl, 'string') // The header one is an API endpoint (a one-click client POSTs to it without // rendering anything); the body one is the site's page. They must differ. assert.notEqual(mails[0].unsubscribeUrl, mails[0].unsubscribeApiUrl) assert.match(mails[0].unsubscribeApiUrl, /\/api\/v1\/public\/teams\/unsubscribe\//) }) // ── 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) }) 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$/) }) 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', last_digest_at: null } function stubDigest({ posts = [], teams = [TEAM], rows = [DIGEST_ROW], sendOk = true } = {}) { patch(notifyModel, 'teamsWithForumActivitySince', async () => teams) patch(notifyModel, 'emailRecipients', async () => rows) patch(notifyModel, 'digestPostsSince', async () => posts) patch(mailer, 'sendTeamNotification', async (msg) => { mails.push(msg) return { sent: sendOk } }) } 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].items.length, 2) }) 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 last_digest_at alone so the window 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) }) test('a successful send stamps exactly that (user, Team)', async () => { stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: 'x
', author_username: 'ten' }] }) const now = new Date() await digest.tick(now) assert.deepEqual(world.stamped, [{ userId: 11, teamId: 1, at: now }]) }) 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', last_digest_at: null }, { user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off', last_digest_at: null }, ], }) await digest.tick(new Date()) assert.deepEqual(mails.map((m) => m.to), ['e@example.test']) }) 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()) })