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>
198 lines
8.9 KiB
JavaScript
198 lines
8.9 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 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)
|
||
})
|
||
|
||
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, emails: 0, 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')
|
||
})
|