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:
parent
fe135cdef5
commit
1c9cb75111
63
apps/bot/src/ipc/pair-state.test.ts
Normal file
63
apps/bot/src/ipc/pair-state.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
apps/bot/src/ipc/pair-state.ts
Normal file
69
apps/bot/src/ipc/pair-state.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user