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>
130 lines
6.3 KiB
JavaScript
130 lines
6.3 KiB
JavaScript
// What Admin → Teams → Notification bridge decides (client/src/lib/teamIntegrations.js).
|
|
//
|
|
// The test that earns this file: **repointing a row must not carry its
|
|
// acknowledgement across.** That is the one way this screen could actively
|
|
// mislead — an operator confirms a private channel, changes the id to a public
|
|
// one, and the form still shows the confirmation as standing. The server clears
|
|
// it either way, so the failure would be a screen that disagrees with the answer
|
|
// it is about to get, which is worse than one that simply refuses.
|
|
//
|
|
// The rest is the boundary of the confirmation dialog: it must open when it
|
|
// matters and stay shut when it does not, because a dialog that appears on saves
|
|
// that did not need it is one people learn to click through.
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import {
|
|
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
|
setChannel, carriesMembersOnly, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
|
} from '../src/lib/teamIntegrations.js'
|
|
|
|
const MEMBERS_ONLY = ['team.forum.post', 'team.announcement']
|
|
const ROSTER = 'team.member.joined'
|
|
const FORUM = 'team.forum.post'
|
|
|
|
const draft = (over = {}) => ({ ...blankDraft(null), ...over })
|
|
|
|
// ── The acknowledgement dies with its channel ──────────────────────────────
|
|
|
|
test('changing the channel drops a standing acknowledgement', () => {
|
|
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
|
const after = setChannel(before, '222')
|
|
assert.equal(after.membersAck, false)
|
|
assert.equal(after.channelRef, '222')
|
|
})
|
|
|
|
test('setting the SAME channel does not clear it — an unrelated re-render is not a repoint', () => {
|
|
const before = draft({ channelRef: '111', membersAck: true })
|
|
const after = setChannel(before, '111')
|
|
assert.equal(after.membersAck, true)
|
|
assert.equal(after, before, 'and the object is returned unchanged, so nothing re-renders')
|
|
})
|
|
|
|
test('a repointed row needs the dialog again, which is the whole point of clearing it', () => {
|
|
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
|
assert.equal(needsAcknowledgement(before, MEMBERS_ONLY), false)
|
|
assert.equal(needsAcknowledgement(setChannel(before, '222'), MEMBERS_ONLY), true)
|
|
})
|
|
|
|
// ── When the dialog opens ──────────────────────────────────────────────────
|
|
|
|
test('enabling a forum event without the tick asks first', () => {
|
|
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), MEMBERS_ONLY), true)
|
|
})
|
|
|
|
test('a DISABLED draft carrying forum events does not ask — nothing is being published yet', () => {
|
|
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: false }), MEMBERS_ONLY), false)
|
|
})
|
|
|
|
test('a roster-only bridge never asks, however it is configured', () => {
|
|
assert.equal(needsAcknowledgement(draft({ events: [ROSTER], enabled: true }), MEMBERS_ONLY), false)
|
|
assert.equal(carriesMembersOnly(draft({ events: [ROSTER] }), MEMBERS_ONLY), false)
|
|
})
|
|
|
|
test('an acknowledgement already given means no second dialog for an unrelated edit', () => {
|
|
const d = draft({ events: [FORUM], enabled: true, membersAck: true, channelRef: '111' })
|
|
const withRoster = toggleEvent(d, ROSTER)
|
|
assert.equal(needsAcknowledgement(withRoster, MEMBERS_ONLY), false)
|
|
})
|
|
|
|
test('the members-only set comes from the server, not from a list held here', () => {
|
|
// The client must not decide what is members-only: a future stream added
|
|
// server-side would silently escape a hardcoded client list.
|
|
assert.deepEqual(
|
|
membersOnlyIdsOf([{ id: ROSTER, membersOnly: false }, { id: FORUM, membersOnly: true }]),
|
|
[FORUM],
|
|
)
|
|
// Told nothing is members-only, the dialog never opens — the server is the one
|
|
// that would then refuse, which is the correct division.
|
|
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), []), false)
|
|
})
|
|
|
|
// ── Events, rows and targets ───────────────────────────────────────────────
|
|
|
|
test('toggling adds then removes, and preserves selection order', () => {
|
|
let d = draft()
|
|
d = toggleEvent(d, FORUM)
|
|
d = toggleEvent(d, ROSTER)
|
|
assert.deepEqual(d.events, [FORUM, ROSTER])
|
|
d = toggleEvent(d, FORUM)
|
|
assert.deepEqual(d.events, [ROSTER])
|
|
})
|
|
|
|
test('the default row is identified by a NULL team, and an undefined one counts too', () => {
|
|
assert.equal(isDefaultRow({ team_id: null }), true)
|
|
assert.equal(isDefaultRow({}), true)
|
|
assert.equal(isDefaultRow({ team_id: 4 }), false)
|
|
assert.equal(rowKey({ team_id: null }), 'default')
|
|
assert.equal(rowKey({ team_id: 4 }), '4')
|
|
})
|
|
|
|
test('a row is labelled by the staff override first, then the name, then its id', () => {
|
|
assert.equal(appliesToLabel({ team_id: null }), 'All Teams')
|
|
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real', display_name_override: 'Shown' }), 'Shown')
|
|
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real' }), 'Real')
|
|
assert.equal(appliesToLabel({ team_id: 4 }), 'Team #4')
|
|
})
|
|
|
|
test('a Team that already has an override is not offered a second one', () => {
|
|
const rows = [{ team_id: null }, { team_id: 2 }]
|
|
const teams = [{ id: 1, status: 'active' }, { id: 2, status: 'active' }, { id: 3, status: 'archived' }]
|
|
const { hasDefault, teams: available } = availableTargets(rows, teams)
|
|
assert.equal(hasDefault, true)
|
|
assert.deepEqual(available.map((t) => t.id), [1], 'the taken one and the archived one are both out')
|
|
})
|
|
|
|
test('with no default configured, the default is still offered', () => {
|
|
const { hasDefault } = availableTargets([{ team_id: 2 }], [])
|
|
assert.equal(hasDefault, false)
|
|
})
|
|
|
|
test('a row round-trips through the draft without changing what it means', () => {
|
|
const row = { team_id: 4, events: [FORUM], channel_ref: '111', enabled: 1, members_ack: 1 }
|
|
assert.deepEqual(draftFrom(row), { teamId: 4, events: [FORUM], channelRef: '111', enabled: true, membersAck: true })
|
|
})
|
|
|
|
test('an unknown event id renders as itself rather than as blank', () => {
|
|
assert.equal(eventLabel(FORUM), 'New forum post')
|
|
assert.equal(eventLabel('team.something.new'), 'team.something.new')
|
|
})
|