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>
This commit is contained in:
197
server/test/teamBridge.test.js
Normal file
197
server/test/teamBridge.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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')
|
||||
})
|
||||
291
server/test/teamIntegration.test.js
Normal file
291
server/test/teamIntegration.test.js
Normal file
@@ -0,0 +1,291 @@
|
||||
// The Team notification bridge (docs/website/TEAMS.md §7.2, phase 8).
|
||||
//
|
||||
// The db layer is stubbed and one in-memory table stands in for
|
||||
// `team_integration_config`, so these are assertions about the RULES rather than
|
||||
// about SQL. What they protect, in order of how badly it would hurt to lose it:
|
||||
//
|
||||
// 1. **A members-only event cannot be enabled without the acknowledgement**,
|
||||
// and the refusal is a 422 rather than a quiet drop at delivery — a config
|
||||
// that says it sends something it does not is worse than one that will not
|
||||
// save;
|
||||
// 2. **changing the channel clears a standing acknowledgement.** This is the
|
||||
// whole reason the tick is a column and not a boolean somebody set once: it
|
||||
// is a statement about a DESTINATION, and repointing the row at a public
|
||||
// channel must not inherit the permission granted for a private one;
|
||||
// 3. **the resolver re-checks the acknowledgement at delivery**, so a row that
|
||||
// lost it stops carrying members-only events immediately rather than at the
|
||||
// next save;
|
||||
// 4. **the override beats the default, and the default is a real row** — the
|
||||
// design-of-record's `PRIMARY KEY (platform, team_id)` could not hold it at
|
||||
// all, so the base case of the whole override mechanism is worth a test;
|
||||
// 5. **a failing lookup reports "unconfigured", not an exception**, because the
|
||||
// caller is a notification path that must never fail the write behind it.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const db = require('../src/model/teams/teamIntegration.db')
|
||||
const model = require('../src/model/teams/teamIntegration.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()
|
||||
}
|
||||
|
||||
// The stand-in stores `team_key` explicitly rather than deriving it on read,
|
||||
// because that is what the generated column does and a test that folded NULL to 0
|
||||
// at lookup time would pass with a schema that never worked.
|
||||
let rows
|
||||
|
||||
const keyOf = (teamId) => (teamId === null || teamId === undefined ? 0 : Number(teamId))
|
||||
|
||||
beforeEach(() => {
|
||||
rows = []
|
||||
let nextId = 1
|
||||
|
||||
patch(db, 'getForTeam', async (platform, teamId) =>
|
||||
rows.find((r) => r.platform === platform && r.team_key === keyOf(teamId)) || null)
|
||||
|
||||
patch(db, 'resolveFor', async (platform, teamId) => {
|
||||
const candidates = rows
|
||||
.filter((r) => r.platform === platform && (r.team_key === 0 || r.team_key === Number(teamId)))
|
||||
.sort((a, b) => b.team_key - a.team_key)
|
||||
return candidates[0] || null
|
||||
})
|
||||
|
||||
patch(db, 'upsert', async (input) => {
|
||||
const key = keyOf(input.teamId)
|
||||
const existing = rows.find((r) => r.platform === input.platform && r.team_key === key)
|
||||
const next = {
|
||||
id: existing ? existing.id : nextId++,
|
||||
platform: input.platform,
|
||||
team_id: input.teamId === null || input.teamId === undefined ? null : Number(input.teamId),
|
||||
team_key: key,
|
||||
events: JSON.stringify(input.events),
|
||||
channel_ref: input.channelRef,
|
||||
enabled: input.enabled ? 1 : 0,
|
||||
members_ack: input.membersAck ? 1 : 0,
|
||||
members_ack_by: input.membersAckBy,
|
||||
members_ack_at: input.membersAckAt,
|
||||
}
|
||||
if (existing) rows[rows.indexOf(existing)] = next
|
||||
else rows.push(next)
|
||||
return next
|
||||
})
|
||||
|
||||
patch(db, 'listForPlatform', async (platform) => rows.filter((r) => r.platform === platform))
|
||||
patch(db, 'remove', async (platform, teamId) => {
|
||||
const before = rows.length
|
||||
rows = rows.filter((r) => !(r.platform === platform && r.team_key === keyOf(teamId)))
|
||||
return before - rows.length
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(restore)
|
||||
|
||||
const ROSTER = 'team.member.joined'
|
||||
const FORUM = 'team.forum.post'
|
||||
|
||||
// ── 1. The acknowledgement is a precondition ───────────────────────────────
|
||||
|
||||
test('enabling a forum event without the acknowledgement is refused 422', async () => {
|
||||
await assert.rejects(
|
||||
() => model.save({ teamId: null, events: [FORUM], channelRef: '123456789', enabled: true }, 7),
|
||||
(err) => {
|
||||
assert.equal(err.status, 422)
|
||||
assert.equal(err.code, 'members_ack_required')
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(rows.length, 0, 'nothing was written')
|
||||
})
|
||||
|
||||
test('a roster-only bridge needs no acknowledgement', async () => {
|
||||
const row = await model.save({ teamId: null, events: [ROSTER], channelRef: '123456789', enabled: true }, 7)
|
||||
assert.equal(row.enabled, true)
|
||||
assert.equal(row.members_ack, false)
|
||||
})
|
||||
|
||||
test('a DISABLED row may carry a forum event without the acknowledgement', async () => {
|
||||
// Drafting a configuration is not publishing one. Refusing the save would make
|
||||
// an operator tick a box before they had decided to turn anything on.
|
||||
const row = await model.save({ teamId: null, events: [FORUM], channelRef: '123456789', enabled: false }, 7)
|
||||
assert.deepEqual(row.events, [FORUM])
|
||||
assert.equal(row.enabled, false)
|
||||
})
|
||||
|
||||
test('an enabled bridge with no channel is refused before the acknowledgement is even considered', async () => {
|
||||
await assert.rejects(
|
||||
() => model.save({ teamId: null, events: [FORUM], channelRef: '', enabled: true, membersAck: true }, 7),
|
||||
(err) => {
|
||||
assert.equal(err.status, 422)
|
||||
assert.match(err.message, /destination channel/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('the acknowledgement records who gave it, and an unrelated save does not re-stamp it', async () => {
|
||||
await model.save({ teamId: null, events: [FORUM], channelRef: '123456789', enabled: true, membersAck: true }, 7)
|
||||
const first = await db.getForTeam('discord', null)
|
||||
assert.equal(first.members_ack_by, 7)
|
||||
assert.ok(first.members_ack_at)
|
||||
|
||||
// A different admin adds a roster event. The acknowledgement is unchanged and
|
||||
// still attributed to the person who actually gave it.
|
||||
await model.save({ teamId: null, events: [FORUM, ROSTER], channelRef: '123456789', enabled: true }, 99)
|
||||
const second = await db.getForTeam('discord', null)
|
||||
assert.equal(second.members_ack_by, 7)
|
||||
assert.deepEqual(second.members_ack_at, first.members_ack_at)
|
||||
})
|
||||
|
||||
// ── 2. The acknowledgement dies with its channel ───────────────────────────
|
||||
|
||||
test('changing the channel clears the acknowledgement — and so refuses the save that would have kept it', async () => {
|
||||
await model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: true }, 7)
|
||||
|
||||
await assert.rejects(
|
||||
() => model.save({ teamId: null, events: [FORUM], channelRef: '222222222', enabled: true }, 7),
|
||||
(err) => err.status === 422 && err.code === 'members_ack_required',
|
||||
)
|
||||
|
||||
// The stored row still points at the original channel: a refused save writes
|
||||
// nothing, so the bridge keeps working against the destination that was vetted.
|
||||
const row = await db.getForTeam('discord', null)
|
||||
assert.equal(row.channel_ref, '111111111')
|
||||
})
|
||||
|
||||
test('changing the channel WITH a fresh acknowledgement saves and re-stamps', async () => {
|
||||
await model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: true }, 7)
|
||||
const row = await model.save(
|
||||
{ teamId: null, events: [FORUM], channelRef: '222222222', enabled: true, membersAck: true },
|
||||
9,
|
||||
)
|
||||
assert.equal(row.channel_ref, '222222222')
|
||||
assert.equal(row.members_ack, true)
|
||||
const stored = await db.getForTeam('discord', null)
|
||||
assert.equal(stored.members_ack_by, 9, 're-acknowledged by whoever repointed it')
|
||||
})
|
||||
|
||||
test('an explicit withdrawal is honoured, and takes the enabled forum bridge with it', async () => {
|
||||
await model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: true }, 7)
|
||||
await assert.rejects(
|
||||
() => model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: false }, 7),
|
||||
(err) => err.status === 422,
|
||||
)
|
||||
})
|
||||
|
||||
// ── 3. The resolver re-asks at delivery ────────────────────────────────────
|
||||
|
||||
test('a row whose acknowledgement was lost stops carrying its members-only events', async () => {
|
||||
await model.save({ teamId: null, events: [FORUM, ROSTER], channelRef: '111111111', enabled: true, membersAck: true }, 7)
|
||||
|
||||
// Simulate the column going false underneath the row — an admin edit through a
|
||||
// path that cleared it, or a future reclassification of what counts as
|
||||
// members-only. The resolver must not serve it on the strength of the save.
|
||||
rows[0].members_ack = 0
|
||||
|
||||
const resolved = await model.resolve(1)
|
||||
assert.deepEqual(resolved.events, [ROSTER], 'the forum event is filtered out, the roster one survives')
|
||||
assert.equal(await model.destinationFor(1, FORUM), null)
|
||||
assert.ok(await model.destinationFor(1, ROSTER))
|
||||
})
|
||||
|
||||
test('a disabled row and a row with no channel both resolve to nothing', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: false }, 7)
|
||||
assert.equal(await model.resolve(1), null)
|
||||
|
||||
rows[0].enabled = 1
|
||||
rows[0].channel_ref = null
|
||||
assert.equal(await model.resolve(1), null)
|
||||
})
|
||||
|
||||
test('destinationFor answers null for an event the row does not carry', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
assert.equal(await model.destinationFor(1, FORUM), null)
|
||||
})
|
||||
|
||||
// ── 4. The default row, and the override that beats it ─────────────────────
|
||||
|
||||
test('the deployment default applies to a Team with no row of its own', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
const destination = await model.destinationFor(42, ROSTER)
|
||||
assert.equal(destination.channelRef, '111111111')
|
||||
})
|
||||
|
||||
test('a per-Team row overrides the default rather than adding to it', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
await model.save({ teamId: 42, events: [ROSTER], channelRef: '222222222', enabled: true }, 7)
|
||||
|
||||
assert.equal((await model.destinationFor(42, ROSTER)).channelRef, '222222222')
|
||||
assert.equal((await model.destinationFor(7, ROSTER)).channelRef, '111111111', 'other Teams keep the default')
|
||||
})
|
||||
|
||||
test('a per-Team row can switch the bridge OFF for one Team while the default stays on', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
await model.save({ teamId: 42, events: [ROSTER], channelRef: '222222222', enabled: false }, 7)
|
||||
|
||||
assert.equal(await model.destinationFor(42, ROSTER), null, 'the override wins even when it disables')
|
||||
assert.ok(await model.destinationFor(7, ROSTER))
|
||||
})
|
||||
|
||||
test('the default row and a per-Team row coexist — the key folds NULL to 0 and nothing collides', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
await model.save({ teamId: 1, events: [ROSTER], channelRef: '222222222', enabled: true }, 7)
|
||||
await model.save({ teamId: 2, events: [ROSTER], channelRef: '333333333', enabled: true }, 7)
|
||||
assert.equal(rows.length, 3)
|
||||
assert.deepEqual(rows.map((r) => r.team_key).sort(), [0, 1, 2])
|
||||
})
|
||||
|
||||
test('removing a per-Team override drops that Team back to the default', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
await model.save({ teamId: 42, events: [ROSTER], channelRef: '222222222', enabled: false }, 7)
|
||||
assert.equal(await model.destinationFor(42, ROSTER), null)
|
||||
|
||||
assert.equal(await model.remove('discord', 42), 1)
|
||||
assert.equal((await model.destinationFor(42, ROSTER)).channelRef, '111111111')
|
||||
})
|
||||
|
||||
// ── 5. Failing closed, and validation ──────────────────────────────────────
|
||||
|
||||
test('a lookup that throws reports "unconfigured" rather than propagating', async () => {
|
||||
patch(db, 'resolveFor', async () => { throw new Error('connection lost') })
|
||||
assert.equal(await model.resolve(1), null)
|
||||
assert.equal(await model.destinationFor(1, ROSTER), null)
|
||||
})
|
||||
|
||||
test('an unknown event id is rejected, not silently dropped', async () => {
|
||||
await assert.rejects(
|
||||
() => model.save({ teamId: null, events: ['team.forum.pots'], channelRef: '111111111', enabled: false }, 7),
|
||||
(err) => err.status === 400 && /unknown event/.test(err.message),
|
||||
)
|
||||
})
|
||||
|
||||
test('duplicate event ids collapse, and a non-array is a 400', async () => {
|
||||
const row = await model.save({ teamId: null, events: [ROSTER, ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
assert.deepEqual(row.events, [ROSTER])
|
||||
await assert.rejects(() => model.save({ teamId: null, events: ROSTER, channelRef: '1', enabled: false }, 7),
|
||||
(err) => err.status === 400)
|
||||
})
|
||||
|
||||
test('a channel ref that is not a plain id is refused', async () => {
|
||||
await assert.rejects(
|
||||
() => model.save({ teamId: null, events: [ROSTER], channelRef: '#general', enabled: true }, 7),
|
||||
(err) => err.status === 400,
|
||||
)
|
||||
})
|
||||
|
||||
test('a corrupt stored event list renders as "bridges nothing" rather than throwing', async () => {
|
||||
await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7)
|
||||
rows[0].events = '{not json'
|
||||
assert.equal(await model.resolve(1), null)
|
||||
const listed = await model.list('discord')
|
||||
assert.deepEqual(listed[0].events, [])
|
||||
})
|
||||
@@ -102,7 +102,10 @@ test('roster events fire one tickle for the run, not one per member', async () =
|
||||
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 })
|
||||
// `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)
|
||||
})
|
||||
|
||||
@@ -121,7 +124,7 @@ test('no recipients means no publish call at all', async () => {
|
||||
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.deepEqual(res, { push: 0, emails: 0, bridged: false })
|
||||
assert.equal(await notify.memberJoined(TEAM), 0)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user