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:
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, [])
|
||||
})
|
||||
Reference in New Issue
Block a user