test(bot): cover post-pair-restart re-warming sequence

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 19:10:46 +08:00
parent d0db248460
commit 40d788302c
3 changed files with 107 additions and 1 deletions

View File

@ -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<string, () => void>();

View File

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

View File

@ -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;
}