From 5fa88baa0a072f026cc8f142de5c5709ec18bb37 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 14:35:23 -0500 Subject: [PATCH] test(teams): the refusals, which is most of what a notification feature is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/test/moduleRegistries.test.js | 15 +- server/test/notificationsRoutes.test.js | 42 ++++ server/test/pushDispatch.test.js | 50 +++++ server/test/teamNotify.test.js | 259 ++++++++++++++++++++++ server/test/teamNotifyDispatch.test.js | 281 ++++++++++++++++++++++++ server/test/teamProvider.test.js | 33 +++ server/test/teamSync.test.js | 41 ++++ 7 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 server/test/teamNotify.test.js create mode 100644 server/test/teamNotifyDispatch.test.js diff --git a/server/test/moduleRegistries.test.js b/server/test/moduleRegistries.test.js index bade2b5..e659321 100644 --- a/server/test/moduleRegistries.test.js +++ b/server/test/moduleRegistries.test.js @@ -44,11 +44,22 @@ function tryApply(owner, build) { test('registerCore registers exactly what core owns, and nothing else', () => { 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 // 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. - 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.equal(registries.slotFilledBy('admin.users.detail'), null) assert.equal(registries.isCoreRegistered(), true) diff --git a/server/test/notificationsRoutes.test.js b/server/test/notificationsRoutes.test.js index adff96e..042a5c9 100644 --- a/server/test/notificationsRoutes.test.js +++ b/server/test/notificationsRoutes.test.js @@ -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/subscriptions'], ['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) { 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() } }) + +// ── 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() + } +}) diff --git a/server/test/pushDispatch.test.js b/server/test/pushDispatch.test.js index 1b4542c..29b4896 100644 --- a/server/test/pushDispatch.test.js +++ b/server/test/pushDispatch.test.js @@ -126,3 +126,53 @@ test('publish skips endpoints that fail the SSRF guard', async () => { 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 }) +}) diff --git a/server/test/teamNotify.test.js b/server/test/teamNotify.test.js new file mode 100644 index 0000000..303d6aa --- /dev/null +++ b/server/test/teamNotify.test.js @@ -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) +}) diff --git a/server/test/teamNotifyDispatch.test.js b/server/test/teamNotifyDispatch.test.js new file mode 100644 index 0000000..11ae236 --- /dev/null +++ b/server/test/teamNotifyDispatch.test.js @@ -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: '

the actual private text

', + }) + 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: '

hello

' }) + 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 = `

${'x'.repeat(500)}

` + 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 Team’s new posts into one mail', async () => { + stubDigest({ + posts: [ + { id: 1, thread_id: 7, title: 'Raid', body_html: '

tonight

', author_username: 'ten' }, + { id: 2, thread_id: 7, title: 'Raid', body_html: '

bring rope

', 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: '

x

', 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: '

x

', 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: '

x

', 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()) +}) diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js index f160d6b..a121276 100644 --- a/server/test/teamProvider.test.js +++ b/server/test/teamProvider.test.js @@ -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 site’s 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=