cm_whatsapp_bot_v1/apps/bot/src/ipc/pair-state.test.ts
yiekheng 40d788302c test(bot): cover post-pair-restart re-warming sequence
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:10:46 +08:00

207 lines
8.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
decideOnPairClose,
decideOnPairTimeout,
decidePairListenerOnClose,
nextWarmingUpAfterEvent,
shouldAutoReconnect,
} from "./pair-state.js";
describe("decideOnPairClose", () => {
it("logged-out close → terminal `logged_out` and wipes QR", () => {
const r = decideOnPairClose({ current: "pending", loggedOut: true });
expect(r).toEqual({ next: "logged_out", clearQrPng: true });
});
it("restart-required close → null (it's a SUCCESS — reconnect, don't touch DB)", () => {
// Regression we just fixed: after the user scans, Baileys closes
// the socket with status 515 ("restart required") so it can
// reopen with the new credentials. Treating that close as a
// failure produced a spurious "Pairing timed out" right at the
// moment the user actually paired successfully.
expect(
decideOnPairClose({ current: "pending", loggedOut: false, restartRequired: true }),
).toBe(null);
});
it("non-loggedOut close from `pending` parks the row as `unpaired`", () => {
const r = decideOnPairClose({ current: "pending", loggedOut: false });
expect(r).toEqual({ next: "unpaired", clearQrPng: true });
});
it("non-loggedOut close from any transient state parks as `unpaired`", () => {
for (const current of ["disconnected", "unpaired", "connected"] as const) {
const r = decideOnPairClose({ current, loggedOut: false });
expect(r).not.toBe(null);
expect(r!.next).toBe("unpaired");
expect(r!.clearQrPng).toBe(true);
}
});
});
describe("decideOnPairTimeout (5-min pair-window expiry)", () => {
it("parks a still-`pending` row as `unpaired`", () => {
expect(decideOnPairTimeout({ current: "pending" })).toEqual({
next: "unpaired",
clearQrPng: true,
});
});
it("does nothing if the row already moved on", () => {
// Don't clobber a successfully-paired account that just happened
// to fire after the timeout for any reason.
for (const current of ["connected", "unpaired", "logged_out", "banned"] as const) {
expect(decideOnPairTimeout({ current })).toBe(null);
}
});
});
describe("shouldAutoReconnect", () => {
it("never reconnects after a logged-out close", () => {
expect(shouldAutoReconnect({ loggedOut: true, hasEverConnected: true })).toBe(false);
expect(shouldAutoReconnect({ loggedOut: true, hasEverConnected: false })).toBe(false);
// Even if Baileys also flagged restartRequired (it shouldn't, but
// be defensive), loggedOut wins.
expect(
shouldAutoReconnect({ loggedOut: true, restartRequired: true, hasEverConnected: true }),
).toBe(false);
});
it("ALWAYS reconnects on restart-required (post-pair-success), even for first-time accounts", () => {
// The regression: brand-new pair attempts have hasEverConnected=false,
// so the old logic refused to reconnect after status 515 — and the
// user got "Pairing timed out" the moment they actually paired.
expect(
shouldAutoReconnect({ loggedOut: false, restartRequired: true, hasEverConnected: false }),
).toBe(true);
expect(
shouldAutoReconnect({ loggedOut: false, restartRequired: true, hasEverConnected: true }),
).toBe(true);
});
it("reconnects only for accounts that have been linked at least once for non-restartRequired drops", () => {
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: true })).toBe(true);
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
});
});
describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => {
it("ignores a close while warming up — even if also restartRequired", () => {
// The exact bug: stop() was awaited, listener attached, then the OLD
// session's close arrives and races our new listener. Warming-up
// wins over every other branch so the UI never sees a spurious
// session.timeout before the new QR is rendered.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: false }),
).toBe("ignore-leaked-close");
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).toBe("ignore-leaked-close");
});
it("treats a close on a live attempt (warmingUp=false) as a real timeout", () => {
// Refs exhausted, network blip, etc. — operator gets the
// "Pairing timed out" screen and a Re-pair affordance.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: false }),
).toBe("treat-as-timeout");
expect(decidePairListenerOnClose({ warmingUp: false })).toBe("treat-as-timeout");
});
it("preserves the restart-required (post-pair-success) branch when not warming up", () => {
// Status 515 close: the session-manager will reconnect and the next
// `open` finishes the pair. We must NOT push session.timeout here.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: true }),
).toBe("post-pair-restart");
});
it("warming-up overrides restartRequired so 515 from a stale session is also swallowed", () => {
// Defense-in-depth: if Baileys' restart-required close from the OLD
// session somehow leaks through, treating it as a real 515 would
// KEEP the listener attached forever (no reconnect comes from a
// session we just stopped). Ignore it entirely until a fresh qr/open.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).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);
});
});