diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index 885ed5f..ecc1751 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -10,7 +10,7 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js"; import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; import { writeAuditLog } from "../audit.js"; import { pgNotifyWeb } from "./notify.js"; -import { decidePairListenerOnClose } from "./pair-state.js"; +import { decidePairListenerOnClose, nextWarmingUpAfterEvent } from "./pair-state.js"; const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const offByAccount = new Map void>(); diff --git a/apps/bot/src/ipc/pair-state.test.ts b/apps/bot/src/ipc/pair-state.test.ts index 6134ccc..ead1371 100644 --- a/apps/bot/src/ipc/pair-state.test.ts +++ b/apps/bot/src/ipc/pair-state.test.ts @@ -3,6 +3,7 @@ import { decideOnPairClose, decideOnPairTimeout, decidePairListenerOnClose, + nextWarmingUpAfterEvent, shouldAutoReconnect, } from "./pair-state.js"; @@ -125,3 +126,81 @@ describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => ).toBe("ignore-leaked-close"); }); }); + +describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => { + it("first qr from the live session clears warming-up", () => { + expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "qr" })).toBe(false); + expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "qr" })).toBe(false); + }); + + it("first open from the live session clears warming-up", () => { + expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "open" })).toBe(false); + expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "open" })).toBe(false); + }); + + it("RE-ARMS warming-up on a restart-required close (post-pair-success)", () => { + // The regression: after the user scans, Baileys closes with status + // 515 and the session-manager schedules a stop().then(start()) + // reconnect. That cleanup-stop emits a SECOND close that arrives + // before the new socket reopens. If warming-up isn't re-armed + // between the two closes, the second one resolves to + // 'treat-as-timeout' and detaches the listener right at the + // moment the user actually paired successfully — UI never gets + // session.connected. + expect( + nextWarmingUpAfterEvent({ warmingUp: false, event: "close", restartRequired: true }), + ).toBe(true); + expect( + nextWarmingUpAfterEvent({ warmingUp: true, event: "close", restartRequired: true }), + ).toBe(true); + }); + + it("plain close leaves warming-up unchanged", () => { + // The pair-handler decides what to DO with a non-restart close + // separately (decidePairListenerOnClose). The warming-up flag + // doesn't change as a side effect — the listener either detaches + // (treat-as-timeout) or already returned (ignore-leaked-close). + expect( + nextWarmingUpAfterEvent({ warmingUp: false, event: "close" }), + ).toBe(false); + expect( + nextWarmingUpAfterEvent({ warmingUp: true, event: "close" }), + ).toBe(true); + }); + + it("end-to-end successful-pair sequence: warming → qr → restart-required close → open", () => { + // Full lifecycle the helper has to thread correctly so the user + // sees 'Account connected!' instead of 'Pairing timed out'. + let warming = true; // freshly attached listener after a re-pair + + // First QR arrives — clears the leak-protection flag. + warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "qr" }); + expect(warming).toBe(false); + + // User scans → Baileys closes with restartRequired=true. + // Re-arms because session-manager will run another stop+start. + warming = nextWarmingUpAfterEvent({ + warmingUp: warming, + event: "close", + restartRequired: true, + }); + expect(warming).toBe(true); + + // The cleanup-stop's second close arrives. The CALLER decides via + // decidePairListenerOnClose to ignore it (warmingUp=true wins). + expect( + decidePairListenerOnClose({ warmingUp: warming, restartRequired: false }), + ).toBe("ignore-leaked-close"); + // Flag stays armed because a plain close doesn't change it. + warming = nextWarmingUpAfterEvent({ + warmingUp: warming, + event: "close", + restartRequired: false, + }); + expect(warming).toBe(true); + + // Fresh socket opens with the new credentials → success. + warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "open" }); + expect(warming).toBe(false); + }); +}); diff --git a/apps/bot/src/ipc/pair-state.ts b/apps/bot/src/ipc/pair-state.ts index 7901c3d..43a1e2d 100644 --- a/apps/bot/src/ipc/pair-state.ts +++ b/apps/bot/src/ipc/pair-state.ts @@ -114,3 +114,30 @@ export function decidePairListenerOnClose(input: { if (input.restartRequired) return "post-pair-restart"; return "treat-as-timeout"; } + +/** + * Step the pair-listener's warming-up flag forward through one Baileys + * event. Captures three rules in one place so they're test-locked: + * + * - First `qr` / `open` from the live session clears warming-up + * (we've seen real session activity, future closes are real). + * - `close + restartRequired` (post-pair-success / status 515) + * RE-ARMS warming-up. The session-manager will schedule a + * `stop().then(start())` reconnect; that stop emits a second close + * before the new socket reopens. Without re-arming, the leaked + * close from the cleanup-stop reaches us with warming-up=false and + * resolves to `treat-as-timeout` — detaching the listener right at + * the moment the user actually paired successfully (regression). + * - Any other `close` keeps warming-up unchanged (the listener + * either ignored it because we're warming, or processed it as a + * real timeout / restart and is leaving the loop anyway). + */ +export function nextWarmingUpAfterEvent(input: { + warmingUp: boolean; + event: "qr" | "open" | "close"; + restartRequired?: boolean; +}): boolean { + if (input.event === "qr" || input.event === "open") return false; + if (input.event === "close" && input.restartRequired) return true; + return input.warmingUp; +}