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>
326 lines
14 KiB
JavaScript
326 lines
14 KiB
JavaScript
// 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'))
|
|
})
|
|
|
|
// ── The SQL itself ─────────────────────────────────────────────────────────
|
|
|
|
test('no query selects the same result column twice', async () => {
|
|
// A defect the live rig found and no stubbed test could: `desiredTeams` and
|
|
// `holdersWithoutClaim` both select `t.id AS team_id`, and the shared column
|
|
// list used to add `i.team_id` beside it. The `mariadb` driver refuses a result
|
|
// set with a repeated field name outright — "Error in results, duplicate field
|
|
// name `team_id`" — so every pass failed at its first query, on a code path
|
|
// every other test in this file stubs.
|
|
//
|
|
// The check runs against the INTERPOLATED sql, captured from a fake `query`,
|
|
// not against the source text: in the source the shared list is still a
|
|
// `${COLUMNS}` placeholder, so a reader — and a first attempt at this test —
|
|
// cannot see the duplicate at all.
|
|
const db = require('../src/utils/db')
|
|
const realQuery = db.query
|
|
const seenSql = []
|
|
db.query = async (sql) => { seenSql.push(sql); return [] }
|
|
|
|
// Re-require: the module destructures `query` at load time, so patching after
|
|
// it is already in the cache would leave it holding the real one.
|
|
delete require.cache[require.resolve('../src/model/teams/teamVoice.db.js')]
|
|
/* eslint-disable-next-line global-require */
|
|
const freshDb = require('../src/model/teams/teamVoice.db.js')
|
|
|
|
try {
|
|
await freshDb.desiredTeams({ platform: 'discord', resource: 'voice', minMembers: 5 })
|
|
await freshDb.holdersWithoutClaim({ platform: 'discord', resource: 'voice', minMembers: 5 })
|
|
await freshDb.listForPlatform('discord', 'voice')
|
|
await freshDb.getForTeam(1, 'discord', 'voice')
|
|
await freshDb.discordSubjectsFor(1)
|
|
} finally {
|
|
db.query = realQuery
|
|
delete require.cache[require.resolve('../src/model/teams/teamVoice.db.js')]
|
|
}
|
|
|
|
assert.equal(seenSql.length, 5, 'every query in the file should have been captured')
|
|
|
|
for (const sql of seenSql) {
|
|
const selectList = sql.slice(sql.search(/SELECT/i) + 6, sql.search(/\sFROM\s/i))
|
|
const names = selectList
|
|
.split(',')
|
|
.map((piece) => piece.trim())
|
|
.filter(Boolean)
|
|
.map((piece) => {
|
|
const aliased = piece.match(/\sAS\s+(\w+)$/i)
|
|
if (aliased) return aliased[1].toLowerCase()
|
|
return piece.replace(/^DISTINCT\s+/i, '').replace(/^\w+\./, '').toLowerCase()
|
|
})
|
|
const seen = new Set()
|
|
const duplicated = names.filter((name) => (seen.has(name) ? true : (seen.add(name), false)))
|
|
assert.deepEqual(duplicated, [], `duplicate result column "${duplicated[0]}" in: ${selectList.trim()}`)
|
|
}
|
|
})
|
|
|
|
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)
|
|
})
|