From 1c9cb751118821dbf62e50a20b7e53c039dede01 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 09:36:45 +0800 Subject: [PATCH] test: pairing-state transitions + accounts overview shows pending rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bot/src/ipc/pair-state.ts (NEW) Pure helpers for the pairing-lifecycle decisions, lifted out of pair-handler so the rules are testable without Baileys / Postgres: - decideOnPairClose({ current, loggedOut }) - decideOnPairTimeout({ current }) - shouldAutoReconnect({ loggedOut, hasEverConnected }) bot/src/ipc/pair-state.test.ts (NEW, 7 tests) Locks in the regressions we just fixed: - Non-loggedOut close from `pending` MUST settle as `unpaired` (the row used to stay `pending` and disappear from the overview). - logged_out close → `logged_out`. - pair-window timeout parks still-`pending` rows; ignores rows that already moved on. - Auto-reconnect only kicks in for accounts that have been linked at least once — guards against the 5-second QR refresh loop on a fresh pair. web/src/components/accounts-list-view.test.tsx + Test that the overview renders accounts in transient states (pending, unpaired, disconnected) alongside connected ones — the `pending` row was being hidden by listAccounts before this fix. Bot: 24 tests passing (+7). Web: 99 tests passing (+1). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/bot/src/ipc/pair-state.test.ts | 63 +++++++++++++++++ apps/bot/src/ipc/pair-state.ts | 69 +++++++++++++++++++ .../components/accounts-list-view.test.tsx | 22 ++++++ 3 files changed, 154 insertions(+) create mode 100644 apps/bot/src/ipc/pair-state.test.ts create mode 100644 apps/bot/src/ipc/pair-state.ts 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", () => {