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>
This commit is contained in:
2026-08-18 14:35:23 -05:00
parent b458c1f46f
commit 5fa88baa0a
7 changed files with 719 additions and 2 deletions

View File

@@ -44,11 +44,22 @@ function tryApply(owner, build) {
test('registerCore registers exactly what core owns, and nothing else', () => { test('registerCore registers exactly what core owns, and nothing else', () => {
registries.registerCore() registries.registerCore()
// One stream, one leg, no filled slot. Before Phase 3 this was eight streams, // Five streams, one leg, no filled slot. Before Phase 3 this was eight streams,
// two legs and a core-filled `admin.users.detail` — core was holding shard // two legs and a core-filled `admin.users.detail` — core was holding shard
// CONTENT so the seam would be exercised on every boot before a module first // CONTENT so the seam would be exercised on every boot before a module first
// used it. module-uo registers all of it now, through the same door. // used it. module-uo registers all of it now, through the same door.
assert.deepEqual(registries.allStreams().map((s) => s.id), ['news.post']) //
// The four `team.*` streams arrived with Teams phase 6 and ARE core's: a module
// supplies who is in a Team, but who may be told about it is the access
// resolver's answer. Asserted as an exact list so a shard-content stream
// creeping back into core's registration fails here rather than shipping.
assert.deepEqual(registries.allStreams().map((s) => s.id), [
'news.post',
'team.member.joined',
'team.leadership.changed',
'team.forum.post',
'team.announcement',
])
assert.deepEqual(registries.announceLegIds(), ['discord']) assert.deepEqual(registries.announceLegIds(), ['discord'])
assert.equal(registries.slotFilledBy('admin.users.detail'), null) assert.equal(registries.slotFilledBy('admin.users.detail'), null)
assert.equal(registries.isCoreRegistered(), true) assert.equal(registries.isCoreRegistered(), true)

View File

@@ -25,6 +25,11 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
['GET', '/api/v1/auth/me/notifications/streams'], ['GET', '/api/v1/auth/me/notifications/streams'],
['GET', '/api/v1/auth/me/notifications/subscriptions'], ['GET', '/api/v1/auth/me/notifications/subscriptions'],
['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }], ['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }],
// Phase 6's two. Per-Team preferences are as much a private fact as the
// subscriptions beside them — the LIST of Teams a user may be notified
// about answers "which guilds is this person in".
['GET', '/api/v1/auth/me/notifications/teams'],
['PUT', '/api/v1/auth/me/notifications/teams', { teams: [] }],
] ]
for (const [method, path, body] of calls) { for (const [method, path, body] of calls) {
const res = await fetch(app.url + path, { const res = await fetch(app.url + path, {
@@ -38,3 +43,40 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
await app.close() await app.close()
} }
}) })
// ── The unsubscribe endpoint (TEAMS.md §6.4) ───────────────────────────────
//
// The mirror image of every test above: this one is reached WITHOUT a session, on
// purpose, because the person following it is reading their mail. So the things
// worth asserting are that it is mounted unauthenticated, that it says the same
// thing whatever the token was, and that the GET twin does not write.
const publicRouter = require('../src/router/v1/public')
test('unsubscribe answers 200 unauthenticated, and says nothing about the token', async () => {
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
try {
// A forged token and a well-formed one must be indistinguishable from the
// outside — otherwise this is an oracle for which (user, Team) pairs exist.
for (const token of ['1.1.1.AAAAAAAAAAAAAAAAAAAAAA', 'total-nonsense']) {
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/${token}`, { method: 'POST' })
assert.equal(res.status, 200)
assert.deepEqual(await res.json(), { ok: true })
}
} finally {
await app.close()
}
})
test('a GET on the same path redirects and does not act', async () => {
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
try {
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/1.1.1.AAAAAAAAAAAAAAAAAAAAAA`, {
redirect: 'manual',
})
assert.equal(res.status, 302)
assert.match(res.headers.get('location') || '', /\/unsubscribe\//)
} finally {
await app.close()
}
})

View File

@@ -126,3 +126,53 @@ test('publish skips endpoints that fail the SSRF guard', async () => {
assert.equal(calls[0].url, 'https://relay.test/ok') assert.equal(calls[0].url, 'https://relay.test/ok')
}) })
}) })
// ── publishToUsers — the third fan-out shape (TEAMS.md §6.2) ───────────────
//
// `publish` answers "everyone subscribed" and "this one owner". Team
// notifications need "these N users", because the four `team.*` streams are
// global and which Team an event belongs to lives in the SET, not the stream id.
test('publishToUsers tickles the given set, and asks for exactly that set', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { calls, fetchImpl } = captureFetch()
const asked = []
const pushDevices = {
endpointsForUsersStream: async (ids, stream) => {
asked.push({ ids, stream })
return [{ endpoint: 'https://relay.test/a' }, { endpoint: 'https://relay.test/b' }]
},
}
await pushDispatch.publishToUsers('team.forum.post', { ref: 'team:1:thread:7', userIds: [4, 9] }, { pushDevices, fetchImpl })
assert.deepEqual(asked, [{ ids: [4, 9], stream: 'team.forum.post' }])
assert.equal(calls.length, 2)
assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'team.forum.post', ref: 'team:1:thread:7' })
})
})
test('publishToUsers with an empty set never touches the database', async () => {
const { calls, fetchImpl } = captureFetch()
let looked = false
const pushDevices = { endpointsForUsersStream: async () => { looked = true; return [] } }
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [] }, { pushDevices, fetchImpl })
assert.equal(looked, false, 'an empty IN () is a syntax error, so the query must not be made at all')
assert.equal(calls.length, 0)
})
test('publishToUsers de-duplicates and drops non-numeric ids', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { fetchImpl } = captureFetch()
const asked = []
const pushDevices = {
endpointsForUsersStream: async (ids) => { asked.push(ids); return [] },
}
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [4, 4, null, 'nope', 9] }, { pushDevices, fetchImpl })
assert.deepEqual(asked, [[4, 9]])
})
})
test('publishToUsers never throws when the lookup fails', async () => {
const { fetchImpl } = captureFetch()
const pushDevices = { endpointsForUsersStream: async () => { throw new Error('down') } }
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [1] }, { pushDevices, fetchImpl })
})

View File

@@ -0,0 +1,259 @@
// 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)
})

View File

@@ -0,0 +1,281 @@
// 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: '<p>the actual private text</p>',
})
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 })
assert.deepEqual(res, { push: 0, emails: 0 })
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 })
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: '<p>hello</p>' })
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 = `<p>${'x'.repeat(500)}</p>`
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 Teams new posts into one mail', async () => {
stubDigest({
posts: [
{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>tonight</p>', author_username: 'ten' },
{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>bring rope</p>', 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: '<p>x</p>', 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: '<p>x</p>', 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: '<p>x</p>', 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())
})

View File

@@ -309,6 +309,39 @@ test('a non-function projectRoster is rejected at registration, not at call time
) )
}) })
// ── pageUrlTemplate: the optional fifth member (§6.4) ──────────────────────
//
// Data, not a method, and the only thing core can use to link to a Team page —
// Teams have no core surface, so the module that owns the page has to say where
// it is. Validated hard because the output goes into an email as a link.
test('pageUrlTemplate is optional: a provider without it registers fine', () => {
const api = registries.stage('uo')
assert.doesNotThrow(() => api.registerTeamProvider(ok()))
})
test('a relative template is kept exactly as given', () => {
register('uo', { ...ok(), pageUrlTemplate: '/uo/guilds/{externalId}' })
assert.equal(registries.registeredTeamProvider().pageUrlTemplate, '/uo/guilds/{externalId}')
})
test('an absolute template is refused — a module may not redirect the sites mail', () => {
const api = registries.stage('uo')
for (const bad of [
'https://evil.test/{externalId}',
'//evil.test/x',
'uo/guilds/{externalId}', // not rooted
'/uo/guilds/{externalId}?x=<script>',
42,
]) {
assert.throws(
() => api.registerTeamProvider({ ...ok(), pageUrlTemplate: bad }),
/pageUrlTemplate must be a relative path/,
`expected "${bad}" to be refused`,
)
}
})
test('an unregistered method cannot ride along into the provider core calls', () => { test('an unregistered method cannot ride along into the provider core calls', () => {
register('uo', { ...ok(), somethingElse: async () => 'hi' }) register('uo', { ...ok(), somethingElse: async () => 'hi' })
assert.equal(registries.registeredTeamProvider().somethingElse, undefined) assert.equal(registries.registeredTeamProvider().somethingElse, undefined)

View File

@@ -13,6 +13,7 @@ const registries = require('../src/modules/registries')
const teamsDb = require('../src/model/teams/teams.db') const teamsDb = require('../src/model/teams/teams.db')
const moderation = require('../src/model/teams/teamModeration.model') const moderation = require('../src/model/teams/teamModeration.model')
const activity = require('../src/model/teams/teamActivity.model') const activity = require('../src/model/teams/teamActivity.model')
const teamNotify = require('../src/utils/teamNotify')
const settings = require('../src/model/settings/settings.model') const settings = require('../src/model/settings/settings.model')
const teamSync = require('../src/model/teams/teamSync.model') const teamSync = require('../src/model/teams/teamSync.model')
@@ -203,6 +204,13 @@ function stubDb() {
store.activity.push(item) store.activity.push(item)
return true return true
}) })
// The push half of the same run (TEAMS.md §6.2, phase 6). Captured the same way
// and for the same reason: the fan-out is its own unit, and what belongs here is
// WHEN the reconciler decides to fire one.
store.tickles = []
patch(teamNotify, 'memberJoined', async () => { store.tickles.push('member.joined') })
patch(teamNotify, 'leadershipChanged', async () => { store.tickles.push('leadership.changed') })
} }
// A provider whose answers the test controls. Defaults are authoritative and // A provider whose answers the test controls. Defaults are authoritative and
@@ -851,6 +859,39 @@ test('the FIRST roster emits nothing — an import is not 155 people joining', a
await teamSync.reconcileNow('setup') await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2, 'the members did land') assert.equal(activeMembers(1).length, 2, 'the members did land')
assert.deepEqual(store.activity, [], 'and none of them was announced') assert.deepEqual(store.activity, [], 'and none of them was announced')
// The same suppression, and the half where it matters more: a notification per
// imported member would wake every phone in a 155-member guild at once.
assert.deepEqual(store.tickles, [], 'and nobody was notified either')
})
test('a roster run tickles ONCE per stream, however many members moved', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([
member('0x1'),
member('0x2', { displayName: 'Brenna' }),
member('0x3', { displayName: 'Cael' }),
]), 'test')
// Two joins, one tickle. The feed above records each of them, because it is a
// record; the tickle says "something happened here" and is content-free, so a
// second identical one carries no second piece of information.
assert.equal(store.activity.length, 2)
assert.deepEqual(store.tickles, ['member.joined'])
})
test('a leadership change tickles its own stream, separately from joins', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, complete: true, members: [member('0x1'), member('0x2')] }),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x2'] }),
}, 'test')
assert.deepEqual(store.tickles, ['leadership.changed'])
}) })
test('a member arriving after the first roster is announced', async () => { test('a member arriving after the first roster is announced', async () => {