Files
website/server/test/teamNotify.test.js
wtclaude 5fa88baa0a test(teams): the refusals, which is most of what a notification feature is
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>
2026-08-18 14:35:23 -05:00

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)
})