{error}
} ++ Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and + override it for individual Teams. A message is sent once and not retried — the bridge is a + courtesy, and nothing on the site depends on it arriving. +
+ + {error &&{error}
} + {notice &&{notice}
} + + {config.rows.length === 0 && !draft && ( +Nothing configured — no Team events leave the site.
+ )} + + {config.rows.length > 0 && ( +| Applies to | Events | Channel | State | |
|---|---|---|---|---|
| + {appliesToLabel(row)} + {isDefaultRow(row) && (default)} + | ++ {row.events.length === 0 + ? none + : row.events.map(eventLabel).join(', ')} + | +{row.channel_ref || unset} | ++ {row.enabled ? 'Enabled' : 'Disabled'} + {row.members_ack && ( + + members-only destination confirmed + {row.members_ack_username ? ` by ${row.members_ack_username}` : ''} + + )} + | ++ + + | +
+ You have confirmed this channel is restricted to the Team’s members.{' '} + +
+ )} + +{line}
+ ))} + + +Meet at the moongate.
', + }) + + 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: 'Read this.
', + }) + 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: 'Hi.
', + }) + 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: 'x
', + }) + 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('Meet at\nthe moongate & wait.
'), 'Meet at the moongate & wait.') +}) + +test('the excerpt is bounded, because an embed description that overflows is rejected wholesale', async () => { + const long = teamBridge.excerpt(`${'x'.repeat(5000)}
`) + 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') +}) diff --git a/server/test/teamIntegration.test.js b/server/test/teamIntegration.test.js new file mode 100644 index 0000000..dec43b9 --- /dev/null +++ b/server/test/teamIntegration.test.js @@ -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, []) +}) diff --git a/server/test/teamNotifyDispatch.test.js b/server/test/teamNotifyDispatch.test.js index 11ae236..1665247 100644 --- a/server/test/teamNotifyDispatch.test.js +++ b/server/test/teamNotifyDispatch.test.js @@ -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) })