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