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