#233 left this open: the forum hid a blocked author's posts from a member, but the member was still pushed, put in the inbox and emailed about them. - teamNotify.forumPost excludes every member who blocks the post's author, or the member who started the thread (the forum 404s that thread for them). One recipient list, so push, the inbox and email skip the same people. userSafety.blockersOf answers it, and a staff author is blocked by nobody (S8). A failed lookup notifies without it. - The daily digest applies each recipient's forumFilter at send time: blocked authors, blocked thread starters, hidden threads and posts. digestPostsSince now also returns the thread's created_by. - A reply's notify payload carries threadAuthorUserId. The Team activity feed needed nothing: forum posts are never written to it (membership, renames and module game events only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
200 lines
9.0 KiB
JavaScript
200 lines
9.0 KiB
JavaScript
// The bridge as a SINK — what actually leaves the site (TEAMS.md §7.2, phase 8).
|
||
//
|
||
// teamIntegration.test.js proves the configuration rules; this proves the wiring
|
||
// that consumes them, which is a different set of mistakes:
|
||
//
|
||
// 1. **the bridge never throws and never blocks its caller.** Every entry point
|
||
// into teamNotify runs after a write has already been answered, so a bot
|
||
// that is down, a config lookup that throws, or a client that rejects must
|
||
// all come back as "no message sent" — an exception here would fail a forum
|
||
// reply that succeeded;
|
||
// 2. **the author is excluded from push and email and NOT from the bridge.**
|
||
// Excluding is a per-recipient idea; a channel has no per-recipient anything,
|
||
// and suppressing the message would silence it for everyone else;
|
||
// 3. **push still runs when the bridge is unconfigured**, which is the ordinary
|
||
// case on every deployment that never turns this on;
|
||
// 4. **a roster event carries a count and never a name.** The count is public;
|
||
// a character name is game-sourced text screened for a page, not a channel;
|
||
// 5. **the excerpt strips markup**, because a forum body is sanitised HTML and
|
||
// an embed description is text.
|
||
const { test, beforeEach, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const teamBridge = require('../src/utils/teamBridge')
|
||
const teamIntegration = require('../src/model/teams/teamIntegration.model')
|
||
const botInternalClient = require('../src/utils/botInternalClient')
|
||
const teamNotify = require('../src/utils/teamNotify')
|
||
const notifyDb = require('../src/model/teams/teamNotify.db')
|
||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||
const pushDispatch = require('../src/utils/pushDispatch')
|
||
const mailer = require('../src/utils/mailer')
|
||
const safety = require('../src/model/safety/userSafety.model')
|
||
|
||
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: 3, name: 'Blackthorn’s Legion', slug: 'blackthorns-legion', external_id: 'g-1' }
|
||
|
||
let sent
|
||
let pushed
|
||
|
||
beforeEach(() => {
|
||
sent = []
|
||
pushed = []
|
||
patch(botInternalClient, 'teamNotify', async (payload) => {
|
||
sent.push(payload)
|
||
return { ok: true, status: 200, data: { posted: true } }
|
||
})
|
||
patch(pushDispatch, 'publishToUsers', async (streamId, opts) => { pushed.push({ streamId, ...opts }) })
|
||
patch(notifyDb, 'recipientIds', async () => [11, 12])
|
||
patch(notifyDb, 'emailRecipients', async () => [])
|
||
patch(forumSettings, 'forumsEnabled', async () => true)
|
||
patch(mailer, 'isConfigured', async () => false)
|
||
patch(safety, 'blockersOf', async () => [])
|
||
})
|
||
|
||
afterEach(restore)
|
||
|
||
const bridgeTo = (channelRef, events) =>
|
||
patch(teamIntegration, 'destinationFor', async (teamId, streamId) =>
|
||
(events.includes(streamId) ? { channelRef, membersOnly: teamIntegration.isMembersOnly(streamId), platform: 'discord' } : null))
|
||
|
||
const noBridge = () => patch(teamIntegration, 'destinationFor', async () => null)
|
||
|
||
// ── 1. Never throws, never blocks ──────────────────────────────────────────
|
||
|
||
test('a bot that rejects the call does not stop the notification path', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
patch(botInternalClient, 'teamNotify', async () => ({ ok: false, status: 503, error: 'bot responded 503' }))
|
||
|
||
const recipients = await teamNotify.memberJoined(TEAM, { count: 2 })
|
||
assert.equal(recipients, 2, 'the push half still reported its recipients')
|
||
assert.equal(pushed.length, 1)
|
||
})
|
||
|
||
test('a config lookup that throws is a bridge that sends nothing, not an exception', async () => {
|
||
patch(teamIntegration, 'destinationFor', async () => { throw new Error('connection lost') })
|
||
assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false)
|
||
})
|
||
|
||
test('a client that throws outright is caught', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
patch(botInternalClient, 'teamNotify', async () => { throw new Error('socket hang up') })
|
||
assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false)
|
||
})
|
||
|
||
test('a team with no id is refused before anything is looked up', async () => {
|
||
patch(teamIntegration, 'destinationFor', async () => { throw new Error('should not be reached') })
|
||
assert.equal(await teamBridge.deliver('team.member.joined', null, {}), false)
|
||
assert.equal(await teamBridge.deliver('team.member.joined', {}, {}), false)
|
||
})
|
||
|
||
// ── 2. The author exclusion stops at the channel ───────────────────────────
|
||
|
||
test('a forum post excludes its author from push but still bridges to the channel', async () => {
|
||
bridgeTo('999', ['team.forum.post'])
|
||
const seen = []
|
||
patch(notifyDb, 'recipientIds', async (teamId, opts) => { seen.push(opts.exclude); return [12] })
|
||
|
||
const result = await teamNotify.forumPost({
|
||
team: TEAM,
|
||
threadId: 41,
|
||
threadTitle: 'Siege tonight',
|
||
type: 'discussion',
|
||
authorUserId: 11,
|
||
authorName: 'ana',
|
||
bodyHtml: '<p>Meet at the moongate.</p>',
|
||
})
|
||
|
||
assert.deepEqual(seen[0], [11], 'the author is excluded from the recipient set')
|
||
assert.equal(result.bridged, true)
|
||
assert.equal(sent.length, 1)
|
||
assert.equal(sent[0].title, 'Siege tonight')
|
||
assert.equal(sent[0].body, 'Meet at the moongate.')
|
||
})
|
||
|
||
test('an announcement bridges on its own stream, not the discussion one', async () => {
|
||
bridgeTo('999', ['team.announcement'])
|
||
await teamNotify.forumPost({
|
||
team: TEAM, threadId: 7, threadTitle: 'Rules', type: 'announcement', bodyHtml: '<p>Read this.</p>',
|
||
})
|
||
assert.equal(sent.length, 1)
|
||
assert.equal(sent[0].streamId, 'team.announcement')
|
||
|
||
// The same event under the other stream is not carried by this configuration.
|
||
sent.length = 0
|
||
await teamNotify.forumPost({
|
||
team: TEAM, threadId: 8, threadTitle: 'Chat', type: 'discussion', bodyHtml: '<p>Hi.</p>',
|
||
})
|
||
assert.equal(sent.length, 0)
|
||
})
|
||
|
||
test('forums switched off silence the bridge as well as the push', async () => {
|
||
bridgeTo('999', ['team.forum.post'])
|
||
patch(forumSettings, 'forumsEnabled', async () => false)
|
||
const result = await teamNotify.forumPost({
|
||
team: TEAM, threadId: 41, threadTitle: 'Siege', type: 'discussion', bodyHtml: '<p>x</p>',
|
||
})
|
||
assert.deepEqual(result, { push: 0, emitted: false, bridged: false })
|
||
assert.equal(sent.length, 0)
|
||
})
|
||
|
||
// ── 3. The ordinary deployment: nothing configured ─────────────────────────
|
||
|
||
test('an unconfigured bridge is silent and costs the push path nothing', async () => {
|
||
noBridge()
|
||
const recipients = await teamNotify.memberJoined(TEAM, { count: 1 })
|
||
assert.equal(recipients, 2)
|
||
assert.equal(pushed.length, 1)
|
||
assert.equal(sent.length, 0)
|
||
})
|
||
|
||
// ── 4. A roster event says how many, never who ─────────────────────────────
|
||
|
||
test('the roster message carries a count and no member name', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
await teamNotify.memberJoined(TEAM, { count: 3 })
|
||
assert.equal(sent[0].body, '3 new members joined.')
|
||
assert.equal(sent[0].title, null, 'a roster event has no title to put a name in')
|
||
})
|
||
|
||
test('the count is singular at one, and degrades honestly with no count at all', async () => {
|
||
assert.equal(teamBridge.memberJoinedBody(1), 'A new member joined.')
|
||
assert.equal(teamBridge.memberJoinedBody(4), '4 new members joined.')
|
||
assert.equal(teamBridge.memberJoinedBody(undefined), 'The roster has changed.')
|
||
assert.equal(teamBridge.memberJoinedBody(0), 'The roster has changed.')
|
||
})
|
||
|
||
test('the tickle stays content-free even when the bridge beside it carries a body', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
await teamNotify.memberJoined(TEAM, { count: 3 })
|
||
assert.deepEqual(pushed[0], { streamId: 'team.member.joined', ref: 'team:3', userIds: [11, 12] })
|
||
})
|
||
|
||
// ── 5. The excerpt ─────────────────────────────────────────────────────────
|
||
|
||
test('the excerpt strips markup, collapses whitespace and decodes entities', async () => {
|
||
assert.equal(teamBridge.excerpt('<p>Meet at\nthe <b>moongate</b> & wait.</p>'), 'Meet at the moongate & wait.')
|
||
})
|
||
|
||
test('the excerpt is bounded, because an embed description that overflows is rejected wholesale', async () => {
|
||
const long = teamBridge.excerpt(`<p>${'x'.repeat(5000)}</p>`)
|
||
assert.equal(long.length, teamBridge.EXCERPT_CHARS)
|
||
assert.ok(long.endsWith('…'))
|
||
})
|
||
|
||
test('the label prefers a staff display-name override, as every other surface does', async () => {
|
||
assert.equal(teamBridge.teamLabel({ name: 'Real', display_name_override: 'Shown' }), 'Shown')
|
||
assert.equal(teamBridge.teamLabel(null), 'a team')
|
||
})
|