diff --git a/apps/bot/src/ipc/pair-state.test.ts b/apps/bot/src/ipc/pair-state.test.ts new file mode 100644 index 0000000..13497b0 --- /dev/null +++ b/apps/bot/src/ipc/pair-state.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { + decideOnPairClose, + decideOnPairTimeout, + 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("non-loggedOut close from `pending` parks the row as `unpaired`", () => { + // This is the regression we just fixed: a failed pair (Baileys + // exhausting QR refs, network blip, user closes the page) was + // leaving the row in `pending` forever, which the accounts list + // hid from the operator. It must now settle 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.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); + }); + + it("reconnects only for accounts that have been linked at least once", () => { + // Regression guard: we used to auto-reconnect any non-loggedOut + // close, which during a fresh pair attempt produced a 5-second QR + // refresh loop because Baileys exhausts QR refs every few seconds + // when the user hasn't scanned yet. + expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: true })).toBe(true); + expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false); + }); +}); diff --git a/apps/bot/src/ipc/pair-state.ts b/apps/bot/src/ipc/pair-state.ts new file mode 100644 index 0000000..8fc51e6 --- /dev/null +++ b/apps/bot/src/ipc/pair-state.ts @@ -0,0 +1,69 @@ +/** + * Pure helpers for pairing-lifecycle status transitions. Extracted so + * the rules are unit-testable without spinning up Baileys / Postgres. + * + * Key invariant the tests guard: + * - A failed or abandoned pair MUST NOT leave the row stuck in + * `pending`. It transitions to `unpaired` so the operator can see + * the account on the list with a Re-pair affordance. + * - Successful pairing transitions to `connected` (set by the + * session-manager on the `open` event — not this helper's job). + * - Auto-reconnect for transient drops only applies to accounts + * that have been linked at least once (`lastConnectedAt` set). + */ + +export type AccountStatus = + | "pending" + | "unpaired" + | "connected" + | "disconnected" + | "logged_out" + | "banned"; + +export interface PairCloseInput { + /** Status of the account row at the moment the close event fires. */ + current: AccountStatus; + /** Did Baileys signal a logged-out close (vs an ephemeral close)? */ + loggedOut: boolean; +} + +export interface StatusUpdate { + next: AccountStatus; + /** Wipe the cached QR PNG when the pair window closes. */ + clearQrPng: boolean; +} + +/** + * Decide the status transition when the Baileys session closes during + * a pairing attempt (i.e. before the user has scanned the QR). + * + * - logged_out close → terminal: `logged_out`. + * - ephemeral close (refs exhausted, network blip, etc.) → park as + * `unpaired` so the row stays visible and the user can retry. + */ +export function decideOnPairClose({ current, loggedOut }: PairCloseInput): StatusUpdate { + if (loggedOut) { + return { next: "logged_out", clearQrPng: true }; + } + // Whatever transient state we were in (most often `pending`), park + // the row as `unpaired` — anything else hides it from the operator. + return { next: "unpaired", clearQrPng: true }; +} + +/** Whether the session-manager should auto-reconnect after a non-loggedOut close. */ +export function shouldAutoReconnect(args: { + loggedOut: boolean; + /** True if the account row has `last_connected_at` set (has been linked before). */ + hasEverConnected: boolean; +}): boolean { + if (args.loggedOut) return false; + return args.hasEverConnected; +} + +/** Decide what happens when the 5-min pair-window timeout fires. */ +export function decideOnPairTimeout({ current }: { current: AccountStatus }): StatusUpdate | null { + // Only the still-pending rows need cleanup. Anything else has already + // moved on (connected, unpaired by an earlier close, etc.). + if (current !== "pending") return null; + return { next: "unpaired", clearQrPng: true }; +} diff --git a/apps/web/src/components/accounts-list-view.test.tsx b/apps/web/src/components/accounts-list-view.test.tsx index e20e24a..801f44c 100644 --- a/apps/web/src/components/accounts-list-view.test.tsx +++ b/apps/web/src/components/accounts-list-view.test.tsx @@ -109,6 +109,28 @@ describe("AccountsListView", () => { expect(html).toContain("Add Account"); expect(html).toMatch(/href="\/accounts\/new"/); }); + + it("includes accounts in transient states (pending, disconnected, unpaired)", () => { + // Regression: the overview was filtering out `pending` rows so + // freshly-paired or failed-pair accounts disappeared. The list now + // shows every status; the badge tells the operator what's going on. + const html = renderToStaticMarkup( + , + ); + // All four cards rendered. + expect((html.match(/data-testid="account-cell"/g) ?? []).length).toBe(4); + expect(html).toContain("Pending One"); + expect(html).toContain("Unpaired One"); + expect(html).toContain("Disconnected One"); + expect(html).toContain("Connected One"); + }); }); describe("layout — empty state", () => {