feat(teams): phase 6 — notifications, and the email sink the web never had #156
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
259
server/test/teamNotify.test.js
Normal file
259
server/test/teamNotify.test.js
Normal 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)
|
||||
})
|
||||
281
server/test/teamNotifyDispatch.test.js
Normal file
281
server/test/teamNotifyDispatch.test.js
Normal 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 Team’s 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())
|
||||
})
|
||||
@@ -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=<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', () => {
|
||||
register('uo', { ...ok(), somethingElse: async () => 'hi' })
|
||||
assert.equal(registries.registeredTeamProvider().somethingElse, undefined)
|
||||
|
||||
@@ -13,6 +13,7 @@ const registries = require('../src/modules/registries')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const moderation = require('../src/model/teams/teamModeration.model')
|
||||
const activity = require('../src/model/teams/teamActivity.model')
|
||||
const teamNotify = require('../src/utils/teamNotify')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const teamSync = require('../src/model/teams/teamSync.model')
|
||||
|
||||
@@ -203,6 +204,13 @@ function stubDb() {
|
||||
store.activity.push(item)
|
||||
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
|
||||
@@ -851,6 +859,39 @@ test('the FIRST roster emits nothing — an import is not 155 people joining', a
|
||||
await teamSync.reconcileNow('setup')
|
||||
assert.equal(activeMembers(1).length, 2, 'the members did land')
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user