fix(teams): the two defects the phase 9 rig walk found
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

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>
This commit is contained in:
2026-08-19 00:07:27 -05:00
parent 61abb3ec89
commit f72c92ffbe
4 changed files with 125 additions and 11 deletions

View File

@@ -24,8 +24,20 @@ const { query } = require('../../utils/db')
// enter, with no error anywhere.
const DISCORD_PROVIDER = 'discord'
// Deliberately WITHOUT `i.team_id`, and this is not tidiness.
//
// The two queries below join `teams` and already select `t.id AS team_id`, so
// including the integration row's copy produces two result columns with the same
// name — which the `mariadb` driver refuses outright: "Error in results, duplicate
// field name `team_id`". Every caller sees the whole pass fail, and no unit test
// can see it, because they stub this layer.
//
// It would also be the WRONG column even if the driver allowed it: `desiredTeams`
// LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel
// yet — the create case, where knowing the Team's id matters most. The two queries
// that do not join `teams` ask for it explicitly.
const COLUMNS = `
i.id, i.team_id, i.platform, i.resource, i.external_ref, i.role_ref,
i.id, i.platform, i.resource, i.external_ref, i.role_ref,
i.state, i.remove_after, i.last_error, i.synced_at, i.updated_at`
/**
@@ -112,7 +124,7 @@ async function discordSubjectsFor(teamId) {
/** Every row for a platform, with the Team's name — the admin panel's listing. */
async function listForPlatform(platform, resource) {
return query(
`SELECT ${COLUMNS}, t.name AS team_name, t.slug AS team_slug,
`SELECT i.team_id, ${COLUMNS}, t.name AS team_name, t.slug AS team_slug,
t.display_name_override, t.status AS team_status, t.hidden AS team_hidden,
t.member_count, t.linked_count
FROM team_integrations i
@@ -125,7 +137,7 @@ async function listForPlatform(platform, resource) {
async function getForTeam(teamId, platform, resource) {
const rows = await query(
`SELECT ${COLUMNS} FROM team_integrations i
`SELECT i.team_id, ${COLUMNS} FROM team_integrations i
WHERE i.team_id = ? AND i.platform = ? AND i.resource = ? LIMIT 1`,
[Number(teamId), platform, resource],
)

View File

@@ -46,6 +46,9 @@ const DEBOUNCE_MS = 30_000
let running = false
let rerun = false
// The promise of the pass currently in flight, so a second caller can await the
// same work rather than be told there is none.
let inFlight = null
let lastRunAt = 0
let debounceTimer = null
@@ -311,12 +314,36 @@ async function runOnce(reason) {
return summary
}
/** Run now, awaited, with the lock held. The admin "sync now" button uses this. */
/**
* Run now, awaited. The admin "Sync now" button uses this.
*
* **A pass already in flight is JOINED, not refused**, the same choice
* `teamSync.reconcileNow` makes and for the same reason: the caller wants "Discord
* now matches", and a pass that started a moment ago delivers exactly that.
*
* Refusing was the first thing written here and it was wrong in a way only the rig
* showed. Saving the settings with voice switched on asks for a pass; an operator
* who then presses Sync now — which is the obvious next thing to do — got
* `ran: false, reason: "a pass is already running"`, and the panel dutifully told
* them **"Nothing was done"** while the pass they had just triggered was busy
* creating their channels.
*/
async function passNow(reason = 'manual') {
if (running) {
rerun = true
return { ran: false, reason: 'a pass is already running', joined: true }
if (inFlight) return inFlight
inFlight = execute(reason)
try {
return await inFlight
} finally {
inFlight = null
if (rerun) {
rerun = false
request({ reason: 'continuation' })
}
}
}
/** The body `passNow` guards. Never throws — see the file header. */
async function execute(reason) {
running = true
try {
const result = await runOnce(reason)
@@ -329,10 +356,6 @@ async function passNow(reason = 'manual') {
return { ran: false, reason: err.message }
} finally {
running = false
if (rerun) {
rerun = false
request({ reason: 'continuation' })
}
}
}
@@ -387,6 +410,7 @@ function _reset() {
stop()
running = false
rerun = false
inFlight = null
lastRunAt = 0
lastPassResult = { at: null, ran: false, reason: 'no pass has run yet' }
}