diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index c1deedc85..f97c444a7 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -236,8 +236,11 @@ describe('useSumsubKycFlow — terminal-error exits clear the user-initiated gua // the entire modal-open, re-running provider submissions for approved-LATAM // self-recovery users (86 in 20 min for one user). The poll now backs off on a // time-escalating schedule (5s for the first minute, then 10s → 20s → 60s) and -// stops entirely after a ~15 min cap. Backoff is time-based, not error-based: -// the poll returns HTTP 200 even when the backend reprocess fails. +// then holds a steady 60s cadence for as long as the modal is open — it never +// hard-stops, so a late/missed websocket event is always eventually recovered +// (the earlier 15-min cap stranded users on "Almost there"). Backoff is +// time-based, not error-based: the poll returns HTTP 200 even when the backend +// reprocess fails. describe('useSumsubKycFlow — verification-progress poll backoff', () => { beforeEach(() => { jest.useFakeTimers() @@ -303,22 +306,44 @@ describe('useSumsubKycFlow — verification-progress poll backoff', () => { expect(mockInitiate).toHaveBeenCalledTimes(19) }) - it('stops polling entirely after the ~15 min cap', async () => { + it('keeps polling past 15 min (no strand) but stays bounded at the 60s steady cadence', async () => { openModal() await act(async () => { await jest.advanceTimersByTimeAsync(15 * 60_000) }) - const countAtCap = mockInitiate.mock.calls.length - // escalation kept this far below a fixed-5s cadence (~180 calls over 15 min). - expect(countAtCap).toBeGreaterThan(0) - expect(countAtCap).toBeLessThan(40) + const countAt15m = mockInitiate.mock.calls.length + // escalation kept the first 15 min far below a fixed-5s cadence (~180). + expect(countAt15m).toBeGreaterThan(0) + expect(countAt15m).toBeLessThan(40) - // past the cap the chain is not rescheduled — no further calls, ever. + // 10 more minutes at the 60s floor → ~10 further polls, NOT zero (the old + // cap stranded the user here) and NOT a 5s-cadence flood. await act(async () => { - await jest.advanceTimersByTimeAsync(30 * 60_000) + await jest.advanceTimersByTimeAsync(10 * 60_000) }) - expect(mockInitiate).toHaveBeenCalledTimes(countAtCap) + const delta = mockInitiate.mock.calls.length - countAt15m + expect(delta).toBeGreaterThanOrEqual(8) + expect(delta).toBeLessThanOrEqual(12) + }) + + it('a status that goes APPROVED after a long wait still surfaces (no strand)', async () => { + const onKycSuccess = jest.fn() + const view = renderHook(() => useSumsubKycFlow({ onKycSuccess })) + act(() => { + view.result.current.handleSdkComplete() + }) + + // 20 minutes of "still pending" — well past the old 15-min cap. + await act(async () => { + await jest.advanceTimersByTimeAsync(20 * 60_000) + }) + // Backend finally approves; the next poll picks it up. + mockInitiate.mockResolvedValue({ data: { token: null, applicantId: 'poll', status: 'APPROVED' } }) + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(onKycSuccess).toHaveBeenCalled() }) it('cancels the pending timer when the modal closes', async () => { diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 99e2e89a2..d16af6deb 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -31,11 +31,15 @@ const KYC_POLL_SCHEDULE: ReadonlyArray<{ untilMs: number; delayMs: number }> = [ { untilMs: 120_000, delayMs: 10_000 }, { untilMs: 180_000, delayMs: 20_000 }, ] +// After the escalation schedule the poll settles at this steady cadence for as +// long as the modal stays open. It does NOT stop: a missed websocket event +// (laptop sleep, mobile background, network switch) can land at any time during +// a long manual review, and a hard stop would strand the user on "Almost there" +// forever with onKycSuccess never firing. The 60s floor plus the backend's own +// self-recovery cooldown (which short-circuits repeat submissions server-side) +// keeps the steady poll cheap — nothing like the fixed-5s battering ram this +// schedule replaced. const KYC_POLL_MAX_DELAY_MS = 60_000 -// Stop polling entirely after this long per modal-open. The modal stays in its -// long-running "Almost there" state and the websocket remains the only signal; -// re-opening the modal restarts polling fresh. -const KYC_POLL_CAP_MS = 15 * 60_000 const getKycPollDelayMs = (elapsedMs: number): number => { for (const { untilMs, delayMs } of KYC_POLL_SCHEDULE) { @@ -149,8 +153,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // (KYC_POLL_SCHEDULE) so the flow can transition even if the websocket event // never arrives — without hammering the mutating initiate endpoint. A // self-rescheduling setTimeout chain (rather than a fixed setInterval) lets - // the delay grow as the modal stays open; polling stops entirely once the - // ~15 min cap is reached, leaving the modal in its long-running state. + // the delay grow as the modal stays open, settling at a steady 60s cadence — + // it keeps polling for the whole modal-open lifetime so a late/missed + // websocket event is always eventually recovered. useEffect(() => { if (!isVerificationProgressModalOpen) return @@ -175,9 +180,6 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const scheduleNext = () => { const elapsed = Date.now() - startedAt - // active-polling cap reached — stop rescheduling; the websocket is the - // only signal from here, and re-opening the modal restarts polling. - if (elapsed >= KYC_POLL_CAP_MS) return timeoutId = setTimeout(async () => { await pollStatus() // the modal may have closed (cleanup ran) while the poll was in