Files
website/server/test/teamBridge.test.js
wtclaude 065bec7ad8
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.

Seven decisions settled by the org lead before any code:

  - email only moves; the push tickle and the Discord bridge stay direct calls
  - the EVENT carries its access-checked audience, and `members` resolves to it
  - the four Team rules are seeded DISABLED, with an admin banner and a note
  - team_notification_prefs stays, read by the engine as a scoped preference
  - the payload wins and a structural projection fills the gaps
  - the digest keeps computing at send time; only its state generalizes
  - an unsubscribe token turns off the channel it names, and nothing else

Three defects found while building it:

  - `email.button` never absolutized its href, while image and itemList both
    did. Every rule-driven CTA would have been a dead relative link, because a
    trigger's url variables are validated site-relative by construction.
  - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
    not to build. An outbox row snapshots the payload and so has none of the
    three properties the digest design exists for, including the security one.
  - the digest's send-log row carried no address_hash while the instant row
    beside it did, which would have made half the mail uncorrelatable in Phase 9.

Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.

Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:11:54 -05:00

198 lines
8.9 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 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: 'Blackthorns 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, 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> &amp; 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')
})