Files
website/server/test/teamVoiceSync.test.js
wtclaude f72c92ffbe
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m33s
fix(teams): the two defects the phase 9 rig walk found
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>
2026-08-19 00:07:27 -05:00

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')
})