Files
website/server/test/teamNotify.test.js
wtclaude 13312d7fc3
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 8m53s
fix(teams): make "replace the whole set" actually replace it
Found walking the live rig, which is the only place it could be found: every unit
test and the settings screen itself send every row, so the bug was invisible to
both.

`PUT /auth/me/notifications/teams` documents itself as replacing the whole set. It
did not — it wrote the entries it was given and left every other preference
standing. So `{"teams": []}` cleared nothing, which is precisely the body the route
requires the array for: the field is mandatory even when empty so that clearing
everything is expressible, and it was the one thing that did not work.

A Team the caller could have named and did not now returns to its defaults. RESET
rather than deleted, and the difference is `last_digest_at`: that column is the
digest worker's state and not a preference, so dropping the row with it would make
every visit to the settings screen re-open a day-wide digest window and mail
somebody a summary they had already read.

Walked again after the fix on the real database: the empty set clears, an entry
naming a Team the caller is not in is still dropped, and the digest stamp survives.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:01:59 -05:00

290 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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