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]);