feat(teams): phase 9 — one voice channel per Team, granted by a role
TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own
and a role that opens it, kept in step by a reconciler that rides the Team
reconcile it already depends on.
Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with
escalation to a role above ~90 members; the org lead settled on roles always
(2026-08-18), which deletes `voice_overwrite_max`, the escalation and the
`mode` column — and moves the ceiling. Overwrites are capped per channel, so
the old shape's limit was "how big can one Team be"; roles are capped per
guild at 250, so the new one is "how many Teams can have voice at all". That
is a limit an operator must be told about before they hit it, so the panel
reports it and the pass refuses the create rather than letting Discord do it.
Three things §7.3 named that this codebase does not have, all settled by
asking the operator because nothing in the data model can answer:
- "the staff role" — there is no staff-role concept anywhere. Now a list of
role ids the admin designates; empty is a normal answer, since guild
administrators bypass overwrites and what is really missing is a way to
let NON-admin staff in.
- the parent category — §7.3 said the bot creates it and gave the id nowhere
to live (`team_integrations.team_id` is NOT NULL). The bot creates it and
the server stores the id in settings.
- whether the bot can act at all — nothing has ever checked. The operator
invites the bot by hand and no invite URL with a permission integer exists
in the tree, so a deployment can be one unticked box from every call
failing. A preflight is now a PRECONDITION to enabling (422), not a
per-Team error discovered afterwards.
Two more, decided rather than asked:
- the threshold counts every active member, not linked ones. §7.3 wrote
`voice_min_linked_members`; the operator is judging whether a Team is real,
and link state answers a different question.
- hidden Teams are never provisioned. A channel name is a game-sourced string
published outside the site, which is exactly §2.8's concern —
reservedNames.js already names "and eventually a Discord channel name" as a
surface it protects — so the screen that suppresses a Team's page suppresses
its channel, and a Team that becomes hidden takes the grace window.
Turning voice OFF tears nothing down: the pass suspends in both directions and
the panel offers per-row removal. A checkbox must not delete structure in
somebody's guild.
Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger`
has been unable to run on `edge` at all. `param('teamId').custom((v) => ... ||
/^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal
followed directly by `.test(`. Hoisted to a const, as modules.router.js
already does. Underneath it, `teams.router.js` sits exactly at that parser's
per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it
generates, and one more statement of ANY shape tips it — an unannotated route
does, and so does a bare `use`. So the voice routes are their own router file
mounted from `admin/index.js`, and teams.router.js keeps its nineteen.
Also breaks a require cycle this phase would have introduced:
teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding
the reconciler's exports object as it stood mid-load — the empty one, since
`module.exports = {…}` replaces rather than fills. The symptom is not in the
new code: it is `teamSync.intervalSeconds is not a function` thrown out of
`syncStatus()`, the freshness banner on every public Team page.
Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes
manifest and guards regenerated; the guard shape of the four new routes is
byte-identical to the existing admin-only ones.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
270
server/test/teamVoice.test.js
Normal file
270
server/test/teamVoice.test.js
Normal file
@@ -0,0 +1,270 @@
|
||||
// Team voice channels — the settings and the plan (docs/website/TEAMS.md §7.3,
|
||||
// phase 9).
|
||||
//
|
||||
// The db layer is stubbed, so these are assertions about the RULES. What they
|
||||
// protect, in order of how badly it would hurt to lose it:
|
||||
//
|
||||
// 1. **A hidden Team is never provisioned.** A Discord channel name is a
|
||||
// game-sourced string published outside the site, which is the exact thing
|
||||
// §2.8 exists to stop, and `reservedNames.js` already names "and eventually
|
||||
// a Discord channel name" as one of the surfaces it protects. Losing this
|
||||
// would put a name staff suppressed into somebody's guild.
|
||||
// 2. **The threshold counts every active member**, not linked ones. §7.3 wrote
|
||||
// `voice_min_linked_members`; the org lead settled it the other way, and the
|
||||
// denormalised column the query reads makes the wrong answer easy to write.
|
||||
// 3. **A Team that stops qualifying is SCHEDULED, not removed** — the grace
|
||||
// window's whole purpose is that a Team hovering around the threshold does
|
||||
// not delete-and-recreate its channel, changing the id and breaking every
|
||||
// pinned link to it.
|
||||
// 4. **A Team that recovers inside the window keeps its channel**, with the
|
||||
// window cleared.
|
||||
// 5. **Every settings read fails closed**, so a DB fault cannot switch voice on,
|
||||
// widen the threshold, or shorten the grace window.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const voiceDb = require('../src/model/teams/teamVoice.db')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const model = require('../src/model/teams/teamVoice.model')
|
||||
const settings = require('../src/model/teams/teamVoiceSettings.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()
|
||||
}
|
||||
|
||||
// One in-memory `settings` table and one `teams` list, driven through the same
|
||||
// queries the real ones answer — including the gate conditions, because a stub
|
||||
// that filtered in JavaScript would pass with SQL that never worked.
|
||||
let store
|
||||
let teams
|
||||
|
||||
const qualifies = (t, minMembers) => t.status === 'active' && !t.hidden && t.member_count >= minMembers
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map()
|
||||
teams = []
|
||||
|
||||
patch(settingsDb, 'get', async (key) => (store.has(key) ? store.get(key) : null))
|
||||
patch(settingsDb, 'set', async (key, value) => { store.set(key, value) })
|
||||
|
||||
patch(voiceDb, 'desiredTeams', async ({ minMembers }) =>
|
||||
teams.filter((t) => qualifies(t, minMembers)).map((t) => ({ ...t, team_id: t.id })))
|
||||
|
||||
patch(voiceDb, 'holdersWithoutClaim', async ({ minMembers }) =>
|
||||
teams
|
||||
.filter((t) => (t.external_ref || t.role_ref) && !qualifies(t, minMembers))
|
||||
.map((t) => ({ ...t, team_id: t.id, team_status: t.status, team_hidden: t.hidden })))
|
||||
})
|
||||
|
||||
afterEach(restore)
|
||||
|
||||
const team = (over = {}) => ({
|
||||
id: 1,
|
||||
name: 'The Silver Hand',
|
||||
display_name_override: null,
|
||||
status: 'active',
|
||||
hidden: 0,
|
||||
member_count: 10,
|
||||
external_ref: null,
|
||||
role_ref: null,
|
||||
state: 'none',
|
||||
remove_after: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
const enable = () => { store.set('teams_voice_enabled', '1') }
|
||||
|
||||
// ── The gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('voice off is not an error: the plan is simply absent', async () => {
|
||||
teams = [team()]
|
||||
assert.equal(await model.plan(), null)
|
||||
})
|
||||
|
||||
test('a hidden Team is never provisioned, however many members it has', async () => {
|
||||
enable()
|
||||
teams = [team({ hidden: 1, member_count: 400 })]
|
||||
const plan = await model.plan()
|
||||
assert.equal(plan.provision.length, 0)
|
||||
})
|
||||
|
||||
test('a hidden Team that already HAS a channel loses it, on the grace window', async () => {
|
||||
enable()
|
||||
teams = [team({ hidden: 1, member_count: 400, external_ref: '900', role_ref: '901' })]
|
||||
const plan = await model.plan()
|
||||
assert.equal(plan.provision.length, 0)
|
||||
assert.equal(plan.scheduled.length, 1)
|
||||
assert.equal(plan.scheduled[0].reason, 'hidden')
|
||||
})
|
||||
|
||||
test('an archived Team loses its channel, and the reason says so', async () => {
|
||||
enable()
|
||||
teams = [team({ status: 'archived', external_ref: '900', role_ref: '901' })]
|
||||
const plan = await model.plan()
|
||||
assert.equal(plan.scheduled[0].reason, 'archived')
|
||||
})
|
||||
|
||||
test('the threshold counts every active member, not the linked ones', async () => {
|
||||
enable()
|
||||
store.set('teams_voice_min_members', '5')
|
||||
// Six members, none of whom has linked anything. §7.3 wrote
|
||||
// `voice_min_linked_members` and this is the settled reading of it: the operator
|
||||
// is judging whether the Team is real, and link state answers a different
|
||||
// question. `linked_count` must not be what the gate reads.
|
||||
teams = [team({ member_count: 6, linked_count: 0 })]
|
||||
const plan = await model.plan()
|
||||
assert.equal(plan.provision.length, 1)
|
||||
})
|
||||
|
||||
test('a Team below the threshold with no channel is simply absent from both lists', async () => {
|
||||
enable()
|
||||
store.set('teams_voice_min_members', '5')
|
||||
teams = [team({ member_count: 2 })]
|
||||
const plan = await model.plan()
|
||||
assert.equal(plan.provision.length, 0)
|
||||
assert.equal(plan.scheduled.length, 0)
|
||||
assert.equal(plan.removals.length, 0)
|
||||
})
|
||||
|
||||
// ── The grace window ───────────────────────────────────────────────────────
|
||||
|
||||
test('a Team that drops below the threshold is scheduled, never removed on the spot', async () => {
|
||||
enable()
|
||||
store.set('teams_voice_min_members', '5')
|
||||
store.set('teams_voice_grace_days', '7')
|
||||
teams = [team({ member_count: 2, external_ref: '900', role_ref: '901' })]
|
||||
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const plan = await model.plan({ now })
|
||||
assert.equal(plan.removals.length, 0)
|
||||
assert.equal(plan.scheduled.length, 1)
|
||||
assert.equal(plan.scheduled[0].reason, 'below_threshold')
|
||||
assert.equal(
|
||||
plan.scheduled[0].removeAfter.toISOString(),
|
||||
new Date('2026-08-26T00:00:00Z').toISOString(),
|
||||
)
|
||||
})
|
||||
|
||||
test('an expired window is what puts a Team in removals', async () => {
|
||||
enable()
|
||||
store.set('teams_voice_min_members', '5')
|
||||
teams = [team({
|
||||
member_count: 2,
|
||||
external_ref: '900',
|
||||
role_ref: '901',
|
||||
state: 'pending_removal',
|
||||
remove_after: '2026-08-18T00:00:00Z',
|
||||
})]
|
||||
const plan = await model.plan({ now: new Date('2026-08-19T00:00:00Z') })
|
||||
assert.equal(plan.removals.length, 1)
|
||||
assert.equal(plan.scheduled.length, 0)
|
||||
})
|
||||
|
||||
test('an unexpired window leaves the row alone — no removal, no re-scheduling', async () => {
|
||||
enable()
|
||||
store.set('teams_voice_min_members', '5')
|
||||
teams = [team({
|
||||
member_count: 2,
|
||||
external_ref: '900',
|
||||
role_ref: '901',
|
||||
state: 'pending_removal',
|
||||
remove_after: '2026-08-30T00:00:00Z',
|
||||
})]
|
||||
const plan = await model.plan({ now: new Date('2026-08-19T00:00:00Z') })
|
||||
assert.equal(plan.removals.length, 0)
|
||||
// Not re-scheduled either: pushing the window out on every pass would mean it
|
||||
// never expires.
|
||||
assert.equal(plan.scheduled.length, 0)
|
||||
})
|
||||
|
||||
test('a Team that recovers inside the window comes back as a provision, flagged as recovering', async () => {
|
||||
enable()
|
||||
store.set('teams_voice_min_members', '5')
|
||||
teams = [team({
|
||||
member_count: 9,
|
||||
external_ref: '900',
|
||||
role_ref: '901',
|
||||
state: 'pending_removal',
|
||||
remove_after: '2026-08-30T00:00:00Z',
|
||||
})]
|
||||
const plan = await model.plan()
|
||||
assert.equal(plan.provision.length, 1)
|
||||
assert.equal(plan.provision[0].recovering, true)
|
||||
assert.equal(plan.removals.length, 0)
|
||||
assert.equal(plan.scheduled.length, 0)
|
||||
})
|
||||
|
||||
// ── Names ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('the display-name override is what reaches Discord, not the game name', async () => {
|
||||
// §2.8.3 lets staff change what is DISPLAYED without touching identity. A
|
||||
// channel is a display surface, so a Team whose name staff rewrote must not go
|
||||
// on publishing the original one.
|
||||
assert.equal(model.displayName({ id: 3, name: 'Bad Name', display_name_override: 'Renamed' }), 'Renamed')
|
||||
})
|
||||
|
||||
test('a name of nothing but control characters falls back rather than reaching Discord empty', async () => {
|
||||
const name = String.fromCharCode(1, 2, 3)
|
||||
assert.equal(model.displayName({ team_id: 42, name }), 'team-42')
|
||||
})
|
||||
|
||||
test('spaces and case survive: a voice channel is not a text channel', async () => {
|
||||
assert.equal(model.sanitiseName(' The Silver Hand '), 'The Silver Hand')
|
||||
})
|
||||
|
||||
test('a name longer than Discord takes is truncated, not rejected', async () => {
|
||||
assert.equal(model.sanitiseName('x'.repeat(400)).length, model.CHANNEL_NAME_MAX)
|
||||
})
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('every settings read fails closed when the database is unreachable', async () => {
|
||||
patch(settingsDb, 'get', async () => { throw new Error('pool is down') })
|
||||
assert.equal(await settings.enabled(), false)
|
||||
assert.equal(await settings.minMembers(), settings.MIN_MEMBERS_DEFAULT)
|
||||
assert.equal(await settings.graceDays(), settings.GRACE_DAYS_DEFAULT)
|
||||
assert.equal(await settings.categoryRef(), null)
|
||||
assert.deepEqual(await settings.staffRoles(), [])
|
||||
})
|
||||
|
||||
test('a stored threshold outside the allowed range is ignored, not obeyed', async () => {
|
||||
store.set('teams_voice_min_members', '0')
|
||||
assert.equal(await settings.minMembers(), settings.MIN_MEMBERS_DEFAULT)
|
||||
store.set('teams_voice_min_members', 'banana')
|
||||
assert.equal(await settings.minMembers(), settings.MIN_MEMBERS_DEFAULT)
|
||||
})
|
||||
|
||||
test('a zero grace window is legitimate and is not confused with an unset one', async () => {
|
||||
store.set('teams_voice_grace_days', '0')
|
||||
assert.equal(await settings.graceDays(), 0)
|
||||
})
|
||||
|
||||
test('a staff-role id that is not an id is refused on save, never silently dropped', async () => {
|
||||
await assert.rejects(
|
||||
() => settings.save({ staffRoles: ['123456789012345678', 'not-an-id'] }),
|
||||
(err) => err.status === 400,
|
||||
)
|
||||
})
|
||||
|
||||
test('staff roles round-trip through storage as a list', async () => {
|
||||
await settings.save({ staffRoles: '123456789012345678, 987654321098765432' })
|
||||
assert.deepEqual(await settings.staffRoles(), ['123456789012345678', '987654321098765432'])
|
||||
})
|
||||
|
||||
test('a category ref that is not a channel id is never stored', async () => {
|
||||
await assert.rejects(() => settings.setCategoryRef('../../etc/passwd'))
|
||||
})
|
||||
|
||||
test('a garbage category ref already in the database reads as unset', async () => {
|
||||
store.set('teams_voice_category_ref', 'nonsense')
|
||||
assert.equal(await settings.categoryRef(), null)
|
||||
})
|
||||
324
server/test/teamVoiceSync.test.js
Normal file
324
server/test/teamVoiceSync.test.js
Normal file
@@ -0,0 +1,324 @@
|
||||
// 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 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')
|
||||
})
|
||||
Reference in New Issue
Block a user