207 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
});
|