// Team notifications (docs/website/TEAMS.md Part 6, phase 6). // // The db layer is stubbed and in-memory tables stand in for `team_members`, // `team_forum_grants` and `team_notification_prefs`, so these are assertions // about the RULES rather than about SQL. Five are worth protecting because each // one is a leak or a nuisance if it is "simplified" away: // // 1. a revoked guest and a departed member are NOT recipients — the fan-out // asks the same two conditions the access resolver asks, and a fan-out that // forgot one would mail a private forum to somebody who was removed from it; // 2. a mute subtracts from the recipient set, per Team, and does not touch the // user's other Teams; // 3. the author of a post is never a recipient of the notification about it; // 4. email is opt-IN (`off` unless chosen) while push is opt-OUT, which is the // one asymmetry in the whole feature and the one most likely to be // "tidied up" into a single default; // 5. an unsubscribe token is honoured for exactly the pair it was signed for, // and forging one is not distinguishable from failing. const { test, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const notifyDb = require('../src/model/teams/teamNotify.db') const notifyModel = require('../src/model/teams/teamNotify.model') const unsubscribeToken = require('../src/utils/unsubscribeToken') 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() } // ── The in-memory stand-in ───────────────────────────────────────────────── // // Modelled on the three real tables rather than on the queries, so a rule the SQL // gets wrong is a rule this gets wrong too. `members` and `grants` carry their // status columns for exactly that reason: the interesting cases are the rows that // exist and do not count. let store function stub() { store = { teams: [ { id: 1, slug: 'silver-hand', name: 'The Silver Hand', display_name_override: null, status: 'active' }, { id: 2, slug: 'iron-few', name: 'The Iron Few', display_name_override: null, status: 'active' }, ], members: [ { team_id: 1, user_id: 10, status: 'active' }, { team_id: 1, user_id: 11, status: 'active' }, { team_id: 1, user_id: 12, status: 'departed' }, // left the guild { team_id: 1, user_id: null, status: 'active' }, // unlinked character { team_id: 2, user_id: 10, status: 'active' }, ], grants: [ { team_id: 1, user_id: 20, revoked_at: null }, // a forum guest { team_id: 1, user_id: 21, revoked_at: '2026-08-01' }, // revoked ], users: [ { id: 10, username: 'ten', email: 'ten@example.test', status: 'active' }, { id: 11, username: 'eleven', email: null, status: 'active' }, { id: 20, username: 'twenty', email: 'twenty@example.test', status: 'active' }, { id: 30, username: 'banned', email: 'banned@example.test', status: 'banned' }, ], prefs: [], // { user_id, team_id, muted, email_mode, last_digest_at } } const prefFor = (userId, teamId) => store.prefs.find((p) => p.user_id === userId && p.team_id === teamId) // The union the real RECIPIENT_UNION builds, with the same two conditions. const union = (teamId) => { const ids = new Set() for (const m of store.members) { if (m.team_id === teamId && m.status === 'active' && m.user_id != null) ids.add(m.user_id) } for (const g of store.grants) { if (g.team_id === teamId && g.revoked_at == null) ids.add(g.user_id) } return [...ids] } patch(notifyDb, 'recipientIds', async (teamId, { exclude = [] } = {}) => union(teamId) .filter((id) => !exclude.includes(id)) .filter((id) => !(prefFor(id, teamId)?.muted))) patch(notifyDb, 'emailRecipients', async (teamId, { exclude = [] } = {}) => union(teamId) .filter((id) => !exclude.includes(id)) .filter((id) => !(prefFor(id, teamId)?.muted)) .map((id) => ({ id, user: store.users.find((u) => u.id === id) })) .filter(({ user }) => user && user.email && user.status === 'active') .map(({ id, user }) => ({ user_id: id, username: user.username, email: user.email, email_mode: prefFor(id, teamId)?.email_mode || 'off', last_digest_at: prefFor(id, teamId)?.last_digest_at || null, }))) patch(notifyDb, 'prefsForUser', async (userId) => store.teams .filter((t) => union(t.id).includes(userId) || prefFor(userId, t.id)) .map((t) => ({ team_id: t.id, slug: t.slug, name: t.name, display_name_override: t.display_name_override, team_status: t.status, muted: prefFor(userId, t.id)?.muted ? 1 : 0, email_mode: prefFor(userId, t.id)?.email_mode || 'off', }))) patch(notifyDb, 'prefFor', async (userId, teamId) => prefFor(userId, teamId)) patch(notifyDb, 'setPref', async (userId, teamId, { muted, emailMode }) => { let row = prefFor(userId, teamId) if (!row) { row = { user_id: userId, team_id: teamId, muted: 0, email_mode: 'off', last_digest_at: null } store.prefs.push(row) } // Only the columns the caller named, exactly as the ON DUPLICATE KEY clause // does — this is the property the unsubscribe path depends on. if (muted != null) row.muted = muted ? 1 : 0 if (emailMode != null) row.email_mode = emailMode }) } beforeEach(stub) afterEach(restore) // ── 1. Access, and the rows that exist but do not count ──────────────────── test('recipients are active linked members plus active grants, and nobody else', async () => { const ids = await notifyModel.recipientIds(1) assert.deepEqual(ids.sort((a, b) => a - b), [10, 11, 20]) }) test('a departed member and a revoked guest are not recipients', async () => { const ids = await notifyModel.recipientIds(1) assert.equal(ids.includes(12), false, 'a departed member is still in the table and must not be notified') assert.equal(ids.includes(21), false, 'a revoked grant is still in the ledger and must not be notified') }) test('an unlinked member contributes no recipient rather than a null one', async () => { const ids = await notifyModel.recipientIds(1) assert.equal(ids.includes(null), false) assert.equal(ids.every((id) => Number.isInteger(id)), true) }) // ── 2. Mutes ─────────────────────────────────────────────────────────────── test('a mute removes that user from that Team only', async () => { await notifyModel.mute(10, 1) assert.deepEqual((await notifyModel.recipientIds(1)).sort((a, b) => a - b), [11, 20]) assert.deepEqual(await notifyModel.recipientIds(2), [10], 'the same user is untouched in another Team') }) test('unmuting puts them back', async () => { await notifyModel.mute(10, 1) await notifyModel.unmute(10, 1) assert.equal((await notifyModel.recipientIds(1)).includes(10), true) }) test('a mute does not disturb an email mode the user chose', async () => { await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'immediate' }]) await notifyModel.mute(10, 1) const pref = await notifyModel.prefFor(10, 1) assert.equal(pref.muted, true) assert.equal(pref.emailMode, 'immediate', 'unsubscribing from one email must not silently rewrite the mode') }) // ── 3. The author ────────────────────────────────────────────────────────── test('the author of a post is excluded from the notification about it', async () => { const ids = await notifyModel.recipientIds(1, { exclude: [10] }) assert.equal(ids.includes(10), false) assert.deepEqual(ids.sort((a, b) => a - b), [11, 20]) }) // ── 4. The two defaults, which point opposite ways ───────────────────────── test('push is opt-OUT: a user who has never configured anything is a recipient', async () => { assert.equal(store.prefs.length, 0) assert.equal((await notifyModel.recipientIds(1)).includes(10), true) }) test('email is opt-IN: the same user is in no email mode until they pick one', async () => { const rows = await notifyModel.emailRecipients(1) assert.equal(rows.every((r) => r.email_mode === 'off'), true) assert.equal(rows.filter((r) => r.email_mode === 'digest' || r.email_mode === 'immediate').length, 0) }) test('a user with no email address is a push recipient and not an email one', async () => { assert.equal((await notifyModel.recipientIds(1)).includes(11), true) assert.equal((await notifyModel.emailRecipients(1)).some((r) => r.user_id === 11), false) }) // ── 5. Preferences a caller may write ────────────────────────────────────── test('replacePrefs ignores a Team the caller is not in', async () => { // User 20 holds a grant on Team 1 and has nothing at all on Team 2. const { written } = await notifyModel.replacePrefs(20, [ { teamId: 1, muted: true, emailMode: 'digest' }, { teamId: 2, muted: true, emailMode: 'digest' }, ]) assert.deepEqual(written, [1]) assert.equal(store.prefs.some((p) => p.user_id === 20 && p.team_id === 2), false) }) test('replacePrefs really replaces: an omitted Team returns to its defaults', async () => { await notifyModel.replacePrefs(10, [ { teamId: 1, muted: true, emailMode: 'immediate' }, { teamId: 2, muted: true, emailMode: 'digest' }, ]) // Now save a set that names only Team 1. Team 2 was not mentioned, so it goes // back to defaults — otherwise "PUT the whole set" is a lie and `teams: []` // clears nothing, which is the body the route requires so that clearing // everything is expressible in the first place. await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'immediate' }]) assert.deepEqual(await notifyModel.prefFor(10, 1), { teamId: 1, muted: true, emailMode: 'immediate' }) assert.deepEqual(await notifyModel.prefFor(10, 2), { teamId: 2, muted: false, emailMode: 'off' }) }) test('an empty set clears every preference the caller holds', async () => { await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'digest' }]) await notifyModel.replacePrefs(10, []) assert.equal((await notifyModel.prefFor(10, 1)).muted, false) assert.equal((await notifyModel.recipientIds(1)).includes(10), true) }) test('the reset does not touch last_digest_at — that is the worker’s column', async () => { await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'digest' }]) const row = store.prefs.find((p) => p.user_id === 10 && p.team_id === 1) row.last_digest_at = '2026-08-18T00:00:00Z' await notifyModel.replacePrefs(10, []) assert.equal(row.last_digest_at, '2026-08-18T00:00:00Z', 'dropping it would re-open a day-wide window on every visit to the settings screen') }) test('an unknown email mode falls back to off rather than reaching the column', async () => { await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'hourly' }]) assert.equal((await notifyModel.prefFor(10, 1)).emailMode, 'off') }) test('listPrefs offers a Team the user has never configured', async () => { const prefs = await notifyModel.listPrefs(10) assert.deepEqual(prefs.map((p) => p.teamId).sort(), [1, 2]) assert.equal(prefs.every((p) => p.muted === false && p.emailMode === 'off'), true) }) test('listPrefs names a Team by its display-name override when staff set one', async () => { store.teams[0].display_name_override = 'A Renamed Guild' const prefs = await notifyModel.listPrefs(10) assert.equal(prefs.find((p) => p.teamId === 1).name, 'A Renamed Guild') }) // ── 6. The unsubscribe token ─────────────────────────────────────────────── test('a v2 token verifies for exactly the channel and scope it was signed for', () => { const token = unsubscribeToken.sign(10, 'email', 'team:1') assert.deepEqual(unsubscribeToken.verify(token), { userId: 10, channel: 'email', scopeKey: 'team:1', version: 2, }) }) // The whole point of keeping v1: a link in a mailbox from before Phase 6 must // still work. It reads as the email channel because an email is the only place a // v1 token can ever have been. test('a v1 token still verifies, and reads as the email channel for that Team', () => { const legacy = unsubscribeToken.signLegacy(10, 1) assert.deepEqual(unsubscribeToken.verify(legacy), { userId: 10, channel: 'email', scopeKey: 'team:1', version: 1, }) }) test('editing the fields in a token invalidates it — the mac covers them', () => { const [v, uid, channel, scope, mac] = unsubscribeToken.sign(10, 'email', 'team:1').split('.') assert.equal(unsubscribeToken.verify(`${v}.99.${channel}.${scope}.${mac}`), null) assert.equal(unsubscribeToken.verify(`${v}.${uid}.${channel}.team:99.${mac}`), null) assert.equal(unsubscribeToken.verify(`${v}.${uid}.push.${scope}.${mac}`), null) const [lv, luid, ltid, lmac] = unsubscribeToken.signLegacy(10, 1).split('.') assert.equal(unsubscribeToken.verify(`${lv}.99.${ltid}.${lmac}`), null) assert.equal(unsubscribeToken.verify(`${lv}.${luid}.99.${lmac}`), null) }) // A channel id may legally contain a dot (`discord.dm`, §3.1's own example) and // the token format's separator is a dot. Refused at signing rather than signed // into something that verifies as a different channel. test('a channel id the format cannot carry is refused at signing, not mangled', () => { assert.throws(() => unsubscribeToken.sign(10, 'discord.dm', 'team:1'), /cannot be carried/) }) test('a garbage token and a well-formed forgery both verify as null', () => { assert.equal(unsubscribeToken.verify('nonsense'), null) assert.equal(unsubscribeToken.verify(''), null) assert.equal(unsubscribeToken.verify(null), null) assert.equal(unsubscribeToken.verify('1.10.1.AAAAAAAAAAAAAAAAAAAAAA'), null) }) test('a version bump is what invalidates every outstanding link at once', () => { const [, uid, channel, scope, mac] = unsubscribeToken.sign(10, 'email', 'team:1').split('.') const next = unsubscribeToken.VERSION + 1 assert.equal(unsubscribeToken.verify(`${next}.${uid}.${channel}.${scope}.${mac}`), null) })