Files
website/server/test/teamVoice.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

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