A notification feature is mostly things that correctly do NOT happen, and each of these is invisible until it goes wrong in production: a departed member and a revoked guest are not recipients; a mute subtracts per Team and leaves the user's other Teams alone; the author of a post never receives the notification about it; forums switched off silences the forum streams including the digest; a Team's first roster wakes nobody; a failed send does not stamp `last_digest_at`. Two real defects came out of writing them. `Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived `filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id 0, so it was harmless — which is exactly why it would never have been noticed. Fixed in all three places that filter ids. `recipientIds: db.recipientIds` in the model captured the function OBJECT at require time, so the layer below could never be substituted. That is not only untestable; it means the model was not really the seam it claimed to be. Wrapped so `db.x` resolves at call time. The registries catalog assertion is now an exact five-element list, so a shard-content stream creeping back into core's registration fails here rather than shipping. Co-Authored-By: Claude <noreply@anthropic.com>
260 lines
12 KiB
JavaScript
260 lines
12 KiB
JavaScript
// 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('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 token verifies for exactly the pair it was signed for', () => {
|
|
const token = unsubscribeToken.sign(10, 1)
|
|
assert.deepEqual(unsubscribeToken.verify(token), { userId: 10, teamId: 1 })
|
|
})
|
|
|
|
test('editing the ids in a token invalidates it — the mac covers them', () => {
|
|
const token = unsubscribeToken.sign(10, 1)
|
|
const [v, uid, tid, mac] = token.split('.')
|
|
assert.equal(unsubscribeToken.verify(`${v}.99.${tid}.${mac}`), null)
|
|
assert.equal(unsubscribeToken.verify(`${v}.${uid}.99.${mac}`), null)
|
|
})
|
|
|
|
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 token = unsubscribeToken.sign(10, 1)
|
|
const [, uid, tid, mac] = token.split('.')
|
|
assert.equal(unsubscribeToken.verify(`${unsubscribeToken.VERSION + 1}.${uid}.${tid}.${mac}`), null)
|
|
})
|