yiekheng 1c9cb75111 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>
2026-05-10 09:36:45 +08:00

70 lines
2.5 KiB
TypeScript

/**
* 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 };
}