// 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, []) })