yiekheng 7af7aa35d0 test(bot): cover the back→re-pair close-leak regression
Extract the pair-handler's close-event decision into a pure helper
decidePairListenerOnClose(warmingUp, restartRequired) returning one of
ignore-leaked-close / post-pair-restart / treat-as-timeout. Refactor
pair-handler to call the helper instead of the inline if-chain.

New tests in pair-state.test.ts:
- warmingUp=true → ignore-leaked-close (regression: prior session's
  close racing the new listener)
- warmingUp=true + restartRequired=true → still ignore (defense in
  depth — a stale 515 must not hand control to the reconnect path)
- warmingUp=false + restartRequired=true → post-pair-restart
- warmingUp=false → treat-as-timeout

Bot suite goes from 60 → 64 tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:06:39 +08:00

117 lines
4.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;
/** Was it the post-pair "restart required" close (status 515)? */
restartRequired?: boolean;
}
export type StatusUpdate = {
next: AccountStatus;
/** Wipe the cached QR PNG when the pair window closes. */
clearQrPng: boolean;
} | null;
/**
* Decide the status transition when the Baileys session closes during
* a pairing attempt.
*
* - logged_out close → terminal: `logged_out`.
* - restart-required close → null (this is a SUCCESS signal that triggers
* a reconnect; the row stays in its current state until `open` fires).
* - 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, restartRequired }: PairCloseInput): StatusUpdate {
if (loggedOut) {
return { next: "logged_out", clearQrPng: true };
}
if (restartRequired) {
// Post-pair-success reconnect — the next `open` event finishes the
// job. Don't touch DB state and don't tear the listener down.
return null;
}
// 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;
restartRequired?: boolean;
/** True if the account row has `last_connected_at` set (has been linked before). */
hasEverConnected: boolean;
}): boolean {
if (args.loggedOut) return false;
// Status 515 is the post-pair-success reconnect — always do it,
// regardless of whether the account has ever connected before.
if (args.restartRequired) return true;
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 };
}
/**
* Decide how the pair-handler should react to a `close` event delivered
* to its listener. Three outcomes:
*
* - "ignore-leaked-close": the new attempt is still warming up and
* we're seeing the OLD session's tail close. Do nothing — don't
* emit timeout to the UI, don't touch the DB row.
* - "post-pair-restart": status-515 close from a successful scan.
* The session-manager will reconnect; we keep the listener alive
* and wait for the subsequent `open` event.
* - "treat-as-timeout": a real ephemeral close on a live attempt
* (refs exhausted, etc.). Park the row as `unpaired` and push
* `session.timeout` to the UI.
*
* Captures the regression where, after the user pulled up a QR and
* navigated back, clicking Pair again would instantly flash "Pairing
* timed out" because the await on stop() returned before
* sessionManager.handleEvent finished broadcasting the old session's
* close — and the new listener was already attached.
*/
export type PairListenerCloseDecision =
| "ignore-leaked-close"
| "post-pair-restart"
| "treat-as-timeout";
export function decidePairListenerOnClose(input: {
warmingUp: boolean;
restartRequired?: boolean;
}): PairListenerCloseDecision {
if (input.warmingUp) return "ignore-leaked-close";
if (input.restartRequired) return "post-pair-restart";
return "treat-as-timeout";
}