From 43660d16e96a7ce4d2ab456c79f9c712c5411334 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 27 May 2026 09:08:52 -0700 Subject: [PATCH] Fix unhandled rejection from session ping when network is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15-minute keep-alive interval in useDraftAuthRecovery called authClient.getSession() with no try-catch. When the network is unavailable (e.g. computer waking from sleep), better-fetch throws a TypeError rather than returning an in-band error, producing an unhandled promise rejection that was surfacing in Sentry. Adds a try-catch that silently swallows network TypeErrors — real session expiry (401) is still caught by the existing !session check because better-fetch returns HTTP errors in-band as { data: null }. Adds tests covering the three ping outcomes: network throw, null session (expired), and valid session. Co-Authored-By: Claude Sonnet 4.6 --- .../useDraftAuthRecovery.session-ping.test.ts | 118 ++++++++++++++++++ app/hooks/useDraftAuthRecovery.ts | 12 +- 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts diff --git a/app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts b/app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts new file mode 100644 index 0000000..ef38bad --- /dev/null +++ b/app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts @@ -0,0 +1,118 @@ +/** + * Tests for the 15-minute session keep-alive ping in useDraftAuthRecovery. + * + * Verifies that: + * - A network TypeError (fetch throws) is silently swallowed and does NOT + * set authDegraded, because a transient network drop should not trigger the + * "Session Expired" overlay mid-draft. + * - A null session response (in-band 401 / expired) DOES set authDegraded. + * - A valid session response leaves authDegraded false. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDraftAuthRecovery } from "../useDraftAuthRecovery"; + +// --------------------------------------------------------------------------- +// Mock auth-client +// --------------------------------------------------------------------------- + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})); + +vi.mock("~/lib/auth-client", () => ({ + authClient: { getSession: mockGetSession }, +})); + +// --------------------------------------------------------------------------- +// Minimal hook params — only what the interval ping needs +// --------------------------------------------------------------------------- + +function makeParams() { + return { + reconnectCount: 0, + revalidate: vi.fn(), + revalidatorState: "idle" as const, + currentUserId: "user-1", + userAutodraftSettings: null, + draftPicks: [], + season: {} as any, + userQueue: [], + timers: [], + autodraftSettings: [], + setPicks: vi.fn(), + setCurrentPick: vi.fn(), + setIsPaused: vi.fn(), + setIsDraftComplete: vi.fn(), + setQueue: vi.fn(), + setTeamTimers: vi.fn(), + setAutodraftStatus: vi.fn(), + setIsSyncing: vi.fn(), + setUserAutodraft: vi.fn(), + }; +} + +const FIFTEEN_MINUTES = 15 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("useDraftAuthRecovery — session keep-alive ping", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockGetSession.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does NOT set authDegraded when getSession() throws a network TypeError", async () => { + mockGetSession.mockRejectedValue(new TypeError("Failed to fetch")); + + const { result } = renderHook(() => useDraftAuthRecovery(makeParams())); + + expect(result.current.authDegraded).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(FIFTEEN_MINUTES); + // flush the async callback + await Promise.resolve(); + }); + + expect(result.current.authDegraded).toBe(false); + }); + + it("sets authDegraded when getSession() returns null data (session expired)", async () => { + mockGetSession.mockResolvedValue({ data: null, error: { status: 401 } }); + + const { result } = renderHook(() => useDraftAuthRecovery(makeParams())); + + expect(result.current.authDegraded).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(FIFTEEN_MINUTES); + await Promise.resolve(); + }); + + expect(result.current.authDegraded).toBe(true); + }); + + it("leaves authDegraded false when getSession() returns a valid session", async () => { + mockGetSession.mockResolvedValue({ + data: { userId: "user-1" }, + error: null, + }); + + const { result } = renderHook(() => useDraftAuthRecovery(makeParams())); + + await act(async () => { + vi.advanceTimersByTime(FIFTEEN_MINUTES); + await Promise.resolve(); + }); + + expect(result.current.authDegraded).toBe(false); + }); +}); diff --git a/app/hooks/useDraftAuthRecovery.ts b/app/hooks/useDraftAuthRecovery.ts index d3df7a6..4273ea5 100644 --- a/app/hooks/useDraftAuthRecovery.ts +++ b/app/hooks/useDraftAuthRecovery.ts @@ -120,8 +120,16 @@ export function useDraftAuthRecovery({ if (authDegraded) return; const FIFTEEN_MINUTES = 15 * 60 * 1000; const id = setInterval(async () => { - const { data: session } = await authClient.getSession(); - if (!session) setAuthDegraded(true); + try { + const { data: session } = await authClient.getSession(); + if (!session) setAuthDegraded(true); + } catch { + // Network failures throw a TypeError from fetch(), but real session + // expiry (401) is returned in-band as { data: null } by better-fetch + // and caught by the !session check above. Swallowing the throw here + // means a transient network drop doesn't trigger the auth overlay + // mid-draft. + } }, FIFTEEN_MINUTES); return () => clearInterval(id); }, [authDegraded]); -- 2.45.3