test: pairing-state transitions + accounts overview shows pending rows

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 09:36:45 +08:00
parent fe135cdef5
commit 1c9cb75111
3 changed files with 154 additions and 0 deletions

View File

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

View File

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

View File

@ -109,6 +109,28 @@ describe("AccountsListView", () => {
expect(html).toContain("Add Account"); expect(html).toContain("Add Account");
expect(html).toMatch(/href="\/accounts\/new"/); 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(
<AccountsListView
accounts={[
mkAccount({ id: "p", label: "Pending One", status: "pending", phoneNumber: null, lastConnectedAt: null }),
mkAccount({ id: "u", label: "Unpaired One", status: "unpaired", phoneNumber: null, lastConnectedAt: null }),
mkAccount({ id: "d", label: "Disconnected One", status: "disconnected", phoneNumber: "+60111222333" }),
mkAccount({ id: "c", label: "Connected One", status: "connected" }),
]}
/>,
);
// 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", () => { describe("layout — empty state", () => {