Walked against real MariaDB, the real app, and a fake standing in for Discord
that mounts the bot's real internal routes — everything up to the Discord API
call was production code. 47 assertions, and it found two things every unit
test in the phase had passed over.
1. **Every query failed: two result columns named `team_id`.** `desiredTeams`
and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared
column list added `i.team_id` beside it. The `mariadb` driver refuses a
result set with a repeated field name outright, so the pass died at its
first query with "Error in results, duplicate field name `team_id`" — on the
one code path every unit test stubs.
It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is
NULL for exactly the Teams that have no channel yet, which is the create
case. The two queries that do not join `teams` now ask for it by name.
The regression test checks the INTERPOLATED sql captured from a fake
`query`, not the source text — in the source the shared list is still a
`${COLUMNS}` placeholder, and a first attempt that read the file passed
happily with the bug reintroduced.
2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the
settings with voice switched on asks for a pass. An operator who then
presses Sync now — the obvious next thing — hit `running` and got back
`ran: false, reason: "a pass is already running"`, which the panel renders
as nothing having happened, while the pass they triggered was busy creating
their channels. A pass in flight is now JOINED and its real outcome
returned, the same choice `teamSync.reconcileNow` makes for the same reason.
Tests: 1162 server (+2), 53 bot, 284 client.
Co-Authored-By: Claude <noreply@anthropic.com>
348 lines
15 KiB
JavaScript
348 lines
15 KiB
JavaScript
// The voice reconciler — what actually reaches Discord (TEAMS.md §7.3, phase 9).
|
|
//
|
|
// teamVoice.test.js proves the rules; this proves the pass that applies them,
|
|
// which is a different set of mistakes:
|
|
//
|
|
// 1. **The three suspensions.** Voice off, a stale Team projection, or a bot
|
|
// that cannot act each stop the pass ENTIRELY — in both directions. The
|
|
// stale one is §7.3 verbatim and is the whole reason the file is careful: a
|
|
// sidecar that has been down for an hour reports rosters that look exactly
|
|
// like "every Team lost its members", and a voice channel must never be
|
|
// destroyed because a sidecar was down.
|
|
// 2. **A per-Team failure does not abort the pass.** One Team whose channel a
|
|
// human deleted is one Team's problem, the same shape as §2.4's gate 3.
|
|
// 3. **A failed sync does not clear the refs it could not confirm.** Clearing
|
|
// them would orphan a real channel and make the next pass create a second.
|
|
// 4. **A failed teardown does not extend the window.** Granting another seven
|
|
// days every time a delete fails means it never happens.
|
|
// 5. **The role cap is checked per create.** A pass with headroom for three
|
|
// Teams must stop after the third rather than discover it in a rejection.
|
|
// 6. **The membership grant is the hop-3 set** — a Team member with no Discord
|
|
// identity cannot be handed a role, so the query, not `linked_count`, is what
|
|
// the pass sends.
|
|
const { test, beforeEach, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const sync = require('../src/utils/teamVoiceSync')
|
|
const voice = require('../src/model/teams/teamVoice.model')
|
|
const voiceDb = require('../src/model/teams/teamVoice.db')
|
|
const settings = require('../src/model/teams/teamVoiceSettings.model')
|
|
const teamsModel = require('../src/model/teams/teams.model')
|
|
const botClient = require('../src/utils/botInternalClient')
|
|
|
|
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()
|
|
}
|
|
|
|
let plan
|
|
let recorded
|
|
let forgotten
|
|
let calls
|
|
let stale
|
|
let flight
|
|
|
|
const okPreflight = {
|
|
connected: true, can_manage_channels: true, can_manage_roles: true, role_count: 12, bot_role_position: 5,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
sync._reset()
|
|
recorded = []
|
|
forgotten = []
|
|
calls = { sync: [], remove: [], preflight: 0 }
|
|
stale = false
|
|
flight = { ok: true, status: 200, data: { ...okPreflight } }
|
|
plan = { config: { enabled: true, minMembers: 5, graceDays: 7, categoryRef: '500', staffRoles: [] }, provision: [], scheduled: [], removals: [] }
|
|
|
|
patch(voice, 'plan', async () => plan)
|
|
patch(voice, 'memberRefs', async () => ['111111111111111111'])
|
|
patch(voice, 'record', async (row) => { recorded.push(row); return row })
|
|
patch(voice, 'forget', async (teamId) => { forgotten.push(teamId) })
|
|
patch(voice, 'getForTeam', async () => null)
|
|
patch(teamsModel, 'syncStatus', async () => ({ stale, lastSyncAt: new Date(), configured: true }))
|
|
patch(settings, 'setCategoryRef', async () => {})
|
|
|
|
patch(botClient, 'voicePreflight', async () => { calls.preflight += 1; return flight })
|
|
patch(botClient, 'voiceSync', async (body) => {
|
|
calls.sync.push(body)
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
data: {
|
|
category_id: body.categoryRef || '500',
|
|
channel_id: '900',
|
|
role_id: '901',
|
|
created: { channel: !body.channelRef, role: !body.roleRef },
|
|
members: { added: 1, removed: 0, pending: 0 },
|
|
},
|
|
}
|
|
})
|
|
patch(botClient, 'voiceRemove', async (body) => { calls.remove.push(body); return { ok: true, status: 200, data: {} } })
|
|
})
|
|
|
|
afterEach(restore)
|
|
|
|
const item = (over = {}) => ({
|
|
team: { team_id: 1, external_ref: null, role_ref: null, remove_after: null, synced_at: null, ...over.team },
|
|
name: 'The Silver Hand',
|
|
hasRow: false,
|
|
recovering: false,
|
|
...over,
|
|
})
|
|
|
|
// ── The three suspensions ──────────────────────────────────────────────────
|
|
|
|
test('voice off: the pass does not run, and makes no calls in either direction', async () => {
|
|
plan = null
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.ran, false)
|
|
assert.equal(calls.preflight, 0)
|
|
assert.equal(calls.sync.length, 0)
|
|
assert.equal(calls.remove.length, 0)
|
|
})
|
|
|
|
test('a stale Team projection stops the pass before a single Discord call', async () => {
|
|
stale = true
|
|
plan.provision = [item()]
|
|
plan.removals = [{ team: { team_id: 2, external_ref: '900', role_ref: '901' }, reason: 'below_threshold' }]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.ran, false)
|
|
assert.match(result.reason, /stale/)
|
|
// The removal half is the one that matters: nothing is destroyed on data core
|
|
// does not trust.
|
|
assert.equal(calls.remove.length, 0)
|
|
assert.equal(calls.sync.length, 0)
|
|
assert.equal(recorded.length, 0)
|
|
})
|
|
|
|
test('a bot missing Manage Roles stops the pass once, not forty times', async () => {
|
|
flight = { ok: true, status: 200, data: { ...okPreflight, can_manage_roles: false } }
|
|
plan.provision = [item(), item({ team: { team_id: 2 } })]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.ran, false)
|
|
assert.match(result.reason, /Manage Roles/)
|
|
// No per-Team error rows: this is one deployment misconfiguration, and writing
|
|
// it into every Team's last_error would bury the one fact that matters.
|
|
assert.equal(recorded.length, 0)
|
|
})
|
|
|
|
test('a bot that is not connected reads as not connected, not as a permission problem', async () => {
|
|
flight = { ok: false, status: 503, error: 'bot responded 503' }
|
|
const result = await sync.preflight()
|
|
assert.equal(result.ready, false)
|
|
assert.match(result.reason, /not connected/)
|
|
})
|
|
|
|
// ── Provisioning ───────────────────────────────────────────────────────────
|
|
|
|
test('a new Team is created, and the pass sends the hop-3 member set', async () => {
|
|
patch(voice, 'memberRefs', async () => ['111111111111111111', '222222222222222222'])
|
|
plan.provision = [item()]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.ran, true)
|
|
assert.equal(result.synced, 1)
|
|
assert.equal(result.created, 1)
|
|
assert.deepEqual(calls.sync[0].memberRefs, ['111111111111111111', '222222222222222222'])
|
|
assert.equal(recorded[0].state, 'active')
|
|
assert.equal(recorded[0].channelRef, '900')
|
|
assert.equal(recorded[0].roleRef, '901')
|
|
})
|
|
|
|
test('a recovering Team has its removal window cleared', async () => {
|
|
plan.provision = [item({
|
|
recovering: true,
|
|
team: { team_id: 1, external_ref: '900', role_ref: '901', remove_after: '2026-08-30T00:00:00Z' },
|
|
})]
|
|
await sync.runOnce('test')
|
|
assert.equal(recorded[0].state, 'active')
|
|
assert.equal(recorded[0].removeAfter, null)
|
|
})
|
|
|
|
test('a failed sync records the error and KEEPS the refs it could not confirm', async () => {
|
|
patch(botClient, 'voiceSync', async () => ({ ok: false, status: 400, data: { message: 'Missing Access' } }))
|
|
plan.provision = [item({ team: { team_id: 1, external_ref: '900', role_ref: '901' } })]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.failed, 1)
|
|
assert.equal(recorded[0].state, 'error')
|
|
assert.equal(recorded[0].lastError, 'Missing Access')
|
|
// Cleared refs would orphan a real channel and make the next pass build a second.
|
|
assert.equal(recorded[0].channelRef, '900')
|
|
assert.equal(recorded[0].roleRef, '901')
|
|
})
|
|
|
|
test('one Team failing does not stop the others', async () => {
|
|
let n = 0
|
|
patch(botClient, 'voiceSync', async (body) => {
|
|
n += 1
|
|
if (n === 1) return { ok: false, status: 400, data: { message: 'Missing Access' } }
|
|
return { ok: true, status: 200, data: { channel_id: '9', role_id: '8', created: {}, members: { pending: 0 } } }
|
|
})
|
|
plan.provision = [item(), item({ team: { team_id: 2 } }), item({ team: { team_id: 3 } })]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.failed, 1)
|
|
assert.equal(result.synced, 2)
|
|
})
|
|
|
|
test('the role cap is enforced per create, before Discord is asked', async () => {
|
|
flight = { ok: true, status: 200, data: { ...okPreflight, role_count: settings.ROLE_CAP - 1 } }
|
|
plan.provision = [item(), item({ team: { team_id: 2 } })]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.created, 1)
|
|
assert.equal(result.failed, 1)
|
|
const capped = recorded.find((r) => r.state === 'error')
|
|
assert.match(capped.lastError, /limit of 250 roles/)
|
|
// The second Team was never handed to the bot.
|
|
assert.equal(calls.sync.length, 1)
|
|
})
|
|
|
|
test('a Team that already HAS a role is synced even at the cap — the cap gates creates, not updates', async () => {
|
|
flight = { ok: true, status: 200, data: { ...okPreflight, role_count: settings.ROLE_CAP } }
|
|
plan.provision = [item({ team: { team_id: 1, external_ref: '900', role_ref: '901' } })]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.synced, 1)
|
|
assert.equal(result.failed, 0)
|
|
})
|
|
|
|
test('a category the bot had to create is persisted, so the next pass does not make another', async () => {
|
|
let stored = null
|
|
patch(settings, 'setCategoryRef', async (value) => { stored = value })
|
|
patch(botClient, 'voiceSync', async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
data: { category_id: '777', channel_id: '900', role_id: '901', created: { channel: true, role: true }, members: { pending: 0 } },
|
|
}))
|
|
plan.config.categoryRef = null
|
|
plan.provision = [item()]
|
|
|
|
await sync.runOnce('test')
|
|
assert.equal(stored, '777')
|
|
})
|
|
|
|
test('a truncated membership diff asks for another pass rather than waiting out the interval', async () => {
|
|
patch(botClient, 'voiceSync', async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
data: { channel_id: '900', role_id: '901', created: {}, members: { added: 50, removed: 0, pending: 30 } },
|
|
}))
|
|
plan.provision = [item()]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.pendingMemberOps, 30)
|
|
})
|
|
|
|
// ── Removal ────────────────────────────────────────────────────────────────
|
|
|
|
test('a scheduled removal writes the window and touches nothing in Discord', async () => {
|
|
const removeAfter = new Date('2026-08-26T00:00:00Z')
|
|
plan.scheduled = [{ team: { team_id: 4, external_ref: '900', role_ref: '901', synced_at: null }, removeAfter, reason: 'below_threshold' }]
|
|
|
|
await sync.runOnce('test')
|
|
assert.equal(calls.remove.length, 0)
|
|
assert.equal(recorded[0].state, 'pending_removal')
|
|
assert.equal(recorded[0].removeAfter, removeAfter)
|
|
})
|
|
|
|
test('an expired removal deletes the channel AND the role, then forgets the row', async () => {
|
|
plan.removals = [{ team: { team_id: 4, external_ref: '900', role_ref: '901' }, reason: 'below_threshold' }]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.removed, 1)
|
|
assert.deepEqual(calls.remove[0], { channelRef: '900', roleRef: '901' })
|
|
assert.deepEqual(forgotten, [4])
|
|
})
|
|
|
|
test('a failed teardown keeps the expired window instead of granting another seven days', async () => {
|
|
patch(botClient, 'voiceRemove', async () => ({ ok: false, status: 0, error: 'fetch failed' }))
|
|
const expired = '2026-08-18T00:00:00Z'
|
|
plan.removals = [{ team: { team_id: 4, external_ref: '900', role_ref: '901', remove_after: expired }, reason: 'archived' }]
|
|
|
|
const result = await sync.runOnce('test')
|
|
assert.equal(result.failed, 1)
|
|
assert.equal(result.removed, 0)
|
|
assert.equal(recorded[0].state, 'error')
|
|
assert.equal(recorded[0].removeAfter, expired)
|
|
// The row survives, so the next pass retries the same teardown.
|
|
assert.deepEqual(forgotten, [])
|
|
})
|
|
|
|
// ── The admin's own removal ────────────────────────────────────────────────
|
|
|
|
test('an admin removal ignores the grace window entirely', async () => {
|
|
patch(voice, 'getForTeam', async () => ({ external_ref: '900', role_ref: '901', remove_after: null }))
|
|
const result = await sync.removeNow(7)
|
|
assert.equal(result.ok, true)
|
|
assert.deepEqual(calls.remove[0], { channelRef: '900', roleRef: '901' })
|
|
assert.deepEqual(forgotten, [7])
|
|
})
|
|
|
|
test('removing a Team that has no channel is a 404, not a silent success', async () => {
|
|
const result = await sync.removeNow(7)
|
|
assert.equal(result.ok, false)
|
|
assert.equal(result.status, 404)
|
|
})
|
|
|
|
// ── The pass never throws ──────────────────────────────────────────────────
|
|
|
|
test('a pass that throws is reported, not raised — it hangs off a background timer', async () => {
|
|
patch(voice, 'plan', async () => { throw new Error('database is on fire') })
|
|
const result = await sync.passNow('test')
|
|
assert.equal(result.ran, false)
|
|
assert.equal(result.reason, 'database is on fire')
|
|
assert.equal(sync.lastPass().ran, false)
|
|
})
|
|
|
|
test('a pass already in flight is JOINED, not refused', async () => {
|
|
// Found on the live rig. Saving the settings with voice switched on asks for a
|
|
// pass; an operator who then presses "Sync now" — the obvious next thing to do —
|
|
// got `ran: false, reason: "a pass is already running"`, and the panel told them
|
|
// **"Nothing was done"** while the pass they had just triggered was creating
|
|
// their channels. `teamSync.reconcileNow` joins for the same reason: the caller
|
|
// wants "Discord now matches", and a pass that started a moment ago delivers it.
|
|
let release
|
|
const gate = new Promise((resolve) => { release = resolve })
|
|
let passes = 0
|
|
patch(voice, 'plan', async () => { passes += 1; await gate; return plan })
|
|
|
|
const first = sync.passNow('first')
|
|
const second = sync.passNow('second')
|
|
release()
|
|
const [a, b] = await Promise.all([first, second])
|
|
|
|
assert.equal(passes, 1, 'the work was done once')
|
|
assert.equal(a.ran, true)
|
|
assert.equal(b.ran, true, 'the second caller got the real outcome, not a refusal')
|
|
assert.deepEqual(a, b)
|
|
})
|
|
|
|
test('a pass records what it concluded, for the panel', async () => {
|
|
plan.provision = [item()]
|
|
await sync.passNow('test')
|
|
const last = sync.lastPass()
|
|
assert.equal(last.ran, true)
|
|
assert.equal(last.synced, 1)
|
|
assert.ok(last.at instanceof Date)
|
|
})
|
|
|
|
test('the db layer is untouched by these tests — the queries are proved by their own file', () => {
|
|
// A guard against a future edit here reaching the real db module: every test in
|
|
// this file stubs the model, and one that did not would connect to the dead port
|
|
// the harness pins and hang.
|
|
assert.equal(typeof voiceDb.desiredTeams, 'function')
|
|
})
|