Files
website/server/test/teamNotifyDispatch.test.js
wtclaude 11b4368b57
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 10m49s
feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
The same Team event as §6, delivered a third time: push, email, and now a
Discord channel the operator configured. Not a second pipeline — teamNotify.js
already computed the recipient set once, so the bridge is a sink beside the two
that were there.

The design's gate has no data source. §7.2 bridges an event only if "its
visibility is public, or its destination channel is configured for a
members-only Team context". The four team.* streams carry no visibility; forum
threads have no public/members column because a forum is members-only by
construction; and core cannot see a Discord channel's permissions. So §7.2's own
example config names exactly the two events that are never public.

The gate is therefore an attributed operator acknowledgement, in the shape
teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet
drop at delivery — it is re-asked at delivery as well as at the save, and
changing the channel clears it, because an acknowledgement is about a
destination and cannot survive the destination changing underneath it.

The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY
KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every
override overrides — is unrepresentable. Proved on a real MariaDB (error 1048).
Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the
unique key, and the foreign key the original had no room for.

One-shot, not queued: "identical to announce and mod-reverse" names two
different reliability models, and a Team notification is the moment it
describes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:25:30 -05:00

285 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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 })
// `bridged` is phase 8's third sink (§7.2). Asserted as part of the shape
// rather than ignored: "forums are off" has to silence every sink, and a test
// that only checked two would not notice a third one still firing.
assert.deepEqual(res, { push: 0, emails: 0, bridged: false })
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, bridged: false })
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())
})