diff --git a/apps/web/src/app/(auth)/callback/page.test.tsx b/apps/web/src/app/(auth)/callback/page.test.tsx index b41c87d..fcf95de 100644 --- a/apps/web/src/app/(auth)/callback/page.test.tsx +++ b/apps/web/src/app/(auth)/callback/page.test.tsx @@ -34,6 +34,8 @@ describe("CallbackPage", (): void => { isLoading: false, isAuthenticated: false, authError: null, + sessionExpiring: false, + sessionMinutesRemaining: 0, signOut: vi.fn(), }); }); @@ -51,6 +53,8 @@ describe("CallbackPage", (): void => { isLoading: false, isAuthenticated: false, authError: null, + sessionExpiring: false, + sessionMinutesRemaining: 0, signOut: vi.fn(), }); @@ -141,6 +145,8 @@ describe("CallbackPage", (): void => { isLoading: false, isAuthenticated: false, authError: null, + sessionExpiring: false, + sessionMinutesRemaining: 0, signOut: vi.fn(), }); diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index 8c73545..ba461aa 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -45,9 +45,8 @@ vi.mock("@/lib/auth/fetch-with-retry", () => ({ })); // Mock parseAuthError to use the real implementation -vi.mock("@/lib/auth/auth-errors", async () => { - const actual = await vi.importActual("@/lib/auth/auth-errors"); - return actual; +vi.mock("@/lib/auth/auth-errors", async (importOriginal) => { + return importOriginal(); }); /* ------------------------------------------------------------------ */ @@ -193,7 +192,9 @@ describe("LoginPage", (): void => { expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); // Should show the unavailability banner (fix #5) - expect(screen.getByText("Some sign-in options may be temporarily unavailable.")).toBeInTheDocument(); + expect( + screen.getByText("Some sign-in options may be temporarily unavailable.") + ).toBeInTheDocument(); }); it("falls back to email-only on non-ok response", async (): Promise => { diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 1c59c08..d09a531 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -58,9 +58,7 @@ export default function LoginPage(): ReactElement { } } catch (err: unknown) { if (!cancelled) { - if (process.env.NODE_ENV === "development") { - console.error("[Auth] Failed to load auth config:", err); - } + console.error("[Auth] Failed to load auth config:", err); setConfig(EMAIL_ONLY_CONFIG); setUrlError("Some sign-in options may be temporarily unavailable."); } @@ -91,9 +89,7 @@ export default function LoginPage(): ReactElement { setError(null); signIn.oauth2({ providerId, callbackURL: "/" }).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); - if (process.env.NODE_ENV === "development") { - console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message); - } + console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message); setError("Unable to connect to the sign-in provider. Please try again in a moment."); setOauthLoading(null); }); @@ -118,9 +114,7 @@ export default function LoginPage(): ReactElement { } } catch (err: unknown) { const parsed = parseAuthError(err); - if (process.env.NODE_ENV === "development") { - console.error("[Auth] Credentials sign-in failed:", err); - } + console.error("[Auth] Credentials sign-in failed:", err); setError(parsed.message); } finally { setCredentialsLoading(false); diff --git a/apps/web/src/lib/auth-client.test.ts b/apps/web/src/lib/auth-client.test.ts index 65879d5..33def1c 100644 --- a/apps/web/src/lib/auth-client.test.ts +++ b/apps/web/src/lib/auth-client.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +/** Mock session shape returned by getSession in tests. */ +interface MockSessionData { + data: { + user: Record; + } | null; +} + /** Words that must never appear in PDA-friendly messages. */ const FORBIDDEN_WORDS = [ "overdue", @@ -31,7 +38,8 @@ vi.mock("./config", () => ({ })); // Import after mocks are set up -const { signInWithCredentials, getAccessToken, isAdmin, getSession } = await import("./auth-client"); +const { signInWithCredentials, getAccessToken, isAdmin, getSession } = + await import("./auth-client"); /** * Helper to build a mock Response object that behaves like the Fetch API Response. @@ -159,14 +167,22 @@ describe("signInWithCredentials", (): void => { }); it("should throw PDA-friendly message when response.json() throws", async (): Promise => { + const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined); const resp = mockResponse({ ok: false, status: 403 }); - vi.mocked(resp.json).mockRejectedValueOnce(new SyntaxError("Unexpected token")); + const jsonError = new SyntaxError("Unexpected token"); + (resp.json as ReturnType).mockRejectedValueOnce(jsonError); vi.mocked(global.fetch).mockResolvedValueOnce(resp); - // json().catch(() => ({})) returns {}, so no message -> falls back to response status + // JSON parse fails -> logs error -> falls back to response status await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "The email and password combination wasn't recognized." ); + + expect(errorSpy).toHaveBeenCalledWith( + "[Auth] Failed to parse error response body (HTTP 403):", + jsonError + ); + errorSpy.mockRestore(); }); }); @@ -182,11 +198,11 @@ describe("signInWithCredentials PDA-friendly language compliance", (): void => { vi.restoreAllMocks(); }); - const errorScenarios: Array<{ + const errorScenarios: { name: string; status: number; body: Record; - }> = [ + }[] = [ { name: "401 with message", status: 401, body: { message: "Unauthorized" } }, { name: "401 without message", status: 401, body: {} }, { name: "403 with message", status: 403, body: { message: "Forbidden" } }, @@ -229,7 +245,7 @@ describe("getAccessToken", (): void => { }); it("should return null when no session exists (session.data is null)", async (): Promise => { - vi.mocked(getSession).mockResolvedValueOnce({ data: null } as any); + vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData); const result = await getAccessToken(); @@ -245,7 +261,7 @@ describe("getAccessToken", (): void => { tokenExpiresAt: Date.now() + 300_000, // 5 minutes from now }, }, - } as any); + } as MockSessionData); const result = await getAccessToken(); @@ -261,7 +277,7 @@ describe("getAccessToken", (): void => { tokenExpiresAt: Date.now() - 120_000, // 2 minutes ago }, }, - } as any); + } as MockSessionData); const result = await getAccessToken(); @@ -277,14 +293,15 @@ describe("getAccessToken", (): void => { tokenExpiresAt: Date.now() + 30_000, // 30 seconds from now (within 60s buffer) }, }, - } as any); + } as MockSessionData); const result = await getAccessToken(); expect(result).toBeNull(); }); - it("should return null when accessToken is undefined on user object", async (): Promise => { + it("should return null and warn when accessToken is undefined on user object", async (): Promise => { + const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { @@ -292,11 +309,25 @@ describe("getAccessToken", (): void => { // no accessToken property }, }, - } as any); + } as MockSessionData); const result = await getAccessToken(); expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith("[Auth] Session exists but no accessToken found"); + warnSpy.mockRestore(); + }); + + it("should return null and log error when getSession throws", async (): Promise => { + const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined); + const sessionError = new Error("Network failure"); + vi.mocked(getSession).mockRejectedValueOnce(sessionError); + + const result = await getAccessToken(); + + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalledWith("[Auth] Failed to get access token:", sessionError); + errorSpy.mockRestore(); }); }); @@ -310,7 +341,7 @@ describe("isAdmin", (): void => { }); it("should return false when no session exists", async (): Promise => { - vi.mocked(getSession).mockResolvedValueOnce({ data: null } as any); + vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData); const result = await isAdmin(); @@ -325,7 +356,7 @@ describe("isAdmin", (): void => { isAdmin: true, }, }, - } as any); + } as MockSessionData); const result = await isAdmin(); @@ -340,7 +371,7 @@ describe("isAdmin", (): void => { isAdmin: false, }, }, - } as any); + } as MockSessionData); const result = await isAdmin(); @@ -355,10 +386,22 @@ describe("isAdmin", (): void => { // no isAdmin property }, }, - } as any); + } as MockSessionData); const result = await isAdmin(); expect(result).toBe(false); }); + + it("should return false and log error when getSession throws", async (): Promise => { + const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined); + const sessionError = new Error("Network failure"); + vi.mocked(getSession).mockRejectedValueOnce(sessionError); + + const result = await isAdmin(); + + expect(result).toBe(false); + expect(errorSpy).toHaveBeenCalledWith("[Auth] Failed to check admin status:", sessionError); + errorSpy.mockRestore(); + }); }); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 0d39c68..3225456 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -43,7 +43,15 @@ export async function signInWithCredentials(username: string, password: string): }); if (!response.ok) { - const errorBody = (await response.json().catch(() => ({}))) as { message?: string }; + let errorBody: { message?: string } = {}; + try { + errorBody = (await response.json()) as { message?: string }; + } catch (jsonError: unknown) { + console.error( + `[Auth] Failed to parse error response body (HTTP ${String(response.status)}):`, + jsonError + ); + } const parsed = parseAuthError(errorBody.message ? new Error(errorBody.message) : response); throw new Error(parsed.message); } @@ -57,37 +65,52 @@ export async function signInWithCredentials(username: string, password: string): * Returns null if not authenticated. */ export async function getAccessToken(): Promise { - const session = await getSession(); - if (!session.data?.user) { + try { + const session = await getSession(); + if (!session.data?.user) { + return null; + } + + // Type assertion for custom user fields + const user = session.data.user as { + accessToken?: string; + tokenExpiresAt?: number; + }; + + if (!user.accessToken) { + console.warn("[Auth] Session exists but no accessToken found"); + return null; + } + + // Check if token is expired (with 1 minute buffer) + if (user.tokenExpiresAt && user.tokenExpiresAt - Date.now() < 60000) { + // Token is expired or about to expire + // The session will be refreshed automatically by BetterAuth + // but we should return null to trigger a re-auth if needed + return null; + } + + return user.accessToken; + } catch (error: unknown) { + console.error("[Auth] Failed to get access token:", error); return null; } - - // Type assertion for custom user fields - const user = session.data.user as { - accessToken?: string; - tokenExpiresAt?: number; - }; - - // Check if token is expired (with 1 minute buffer) - if (user.tokenExpiresAt && user.tokenExpiresAt - Date.now() < 60000) { - // Token is expired or about to expire - // The session will be refreshed automatically by BetterAuth - // but we should return null to trigger a re-auth if needed - return null; - } - - return user.accessToken ?? null; } /** * Check if the current user is an admin. */ export async function isAdmin(): Promise { - const session = await getSession(); - if (!session.data?.user) { + try { + const session = await getSession(); + if (!session.data?.user) { + return false; + } + + const user = session.data.user as { isAdmin?: boolean }; + return user.isAdmin === true; + } catch (error: unknown) { + console.error("[Auth] Failed to check admin status:", error); return false; } - - const user = session.data.user as { isAdmin?: boolean }; - return user.isAdmin === true; } diff --git a/apps/web/src/lib/auth/auth-context.test.tsx b/apps/web/src/lib/auth/auth-context.test.tsx index 5ff97e5..ab92a15 100644 --- a/apps/web/src/lib/auth/auth-context.test.tsx +++ b/apps/web/src/lib/auth/auth-context.test.tsx @@ -101,6 +101,10 @@ describe("AuthContext", (): void => { }); it("should handle unauthenticated state when session check fails", async (): Promise => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { + // Intentionally empty - suppressing log output in tests + }); + vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized")); render( @@ -114,6 +118,8 @@ describe("AuthContext", (): void => { }); expect(screen.queryByTestId("user-email")).not.toBeInTheDocument(); + + consoleErrorSpy.mockRestore(); }); it("should clear user on sign out", async (): Promise => { @@ -166,8 +172,13 @@ describe("AuthContext", (): void => { }); describe("auth error handling", (): void => { - it("should not set authError for normal unauthenticated state (401/403)", async (): Promise => { - // Normal auth error - user is just not logged in + it("should set authError to 'backend' for unrecognised auth errors (e.g. 401/403)", async (): Promise => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { + // Intentionally empty + }); + + // Error instances that don't match network/server keywords default to "backend" + // so they surface a banner rather than silently logging the user out vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized")); render( @@ -180,11 +191,17 @@ describe("AuthContext", (): void => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); - // Should NOT have an auth error - this is expected behavior - expect(screen.getByTestId("auth-error")).toHaveTextContent("none"); + // With classifyAuthError, unrecognised Error instances default to "backend" + expect(screen.getByTestId("auth-error")).toHaveTextContent("backend"); + + consoleErrorSpy.mockRestore(); }); it("should set authError to 'network' for fetch failures", async (): Promise => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { + // Intentionally empty + }); + // Network error - backend is unreachable vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch")); @@ -200,12 +217,11 @@ describe("AuthContext", (): void => { // Should have a network error expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); + + consoleErrorSpy.mockRestore(); }); - it("should log errors in development mode", async (): Promise => { - // Temporarily set to development - vi.stubEnv("NODE_ENV", "development"); - + it("should always log auth errors (including production)", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty - we're testing that errors are logged }); @@ -223,14 +239,13 @@ describe("AuthContext", (): void => { expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); }); - // Should log error in development + // Should log error regardless of NODE_ENV expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining("[Auth]"), expect.any(Error) ); consoleErrorSpy.mockRestore(); - vi.unstubAllEnvs(); }); it("should set authError to 'network' for connection refused", async (): Promise => { @@ -238,7 +253,8 @@ describe("AuthContext", (): void => { // Intentionally empty }); - vi.mocked(apiGet).mockRejectedValueOnce(new Error("ECONNREFUSED")); + // "Connection refused" includes "connection" which parseAuthError maps to network_error + vi.mocked(apiGet).mockRejectedValueOnce(new Error("Connection refused")); render( @@ -453,6 +469,7 @@ describe("AuthContext", (): void => { // Advance 2 minutes - should now be within the 5-minute window (4 min remaining) await act(async () => { vi.advanceTimersByTime(2 * 60 * 1000); + await Promise.resolve(); }); expect(screen.getByTestId("session-expiring")).toHaveTextContent("true"); @@ -460,7 +477,7 @@ describe("AuthContext", (): void => { vi.useRealTimers(); }); - it("should log out user when session expires via interval", async (): Promise => { + it("should log out user and set session_expired when session expires via interval", async (): Promise => { vi.useFakeTimers(); // Session expires 30 seconds from now @@ -486,10 +503,13 @@ describe("AuthContext", (): void => { // Advance past the expiry time (triggers the 60s interval) await act(async () => { vi.advanceTimersByTime(60 * 1000); + await Promise.resolve(); }); expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); + // Session expiry now sets explicit session_expired error state + expect(screen.getByTestId("auth-error")).toHaveTextContent("session_expired"); vi.useRealTimers(); }); diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx index 29a45ec..1602590 100644 --- a/apps/web/src/lib/auth/auth-context.tsx +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -11,11 +11,12 @@ import { } from "react"; import type { AuthUser, AuthSession } from "@mosaic/shared"; import { apiGet, apiPost } from "../api/client"; +import { parseAuthError } from "./auth-errors"; /** * Error types for auth session checks */ -export type AuthErrorType = "network" | "backend" | null; +export type AuthErrorType = "network" | "backend" | "session_expired" | null; /** Threshold in minutes before session expiry to start warning */ const SESSION_EXPIRY_WARNING_MINUTES = 5; @@ -37,51 +38,29 @@ interface AuthContextValue { const AuthContext = createContext(undefined); /** - * Check if an error indicates a network/backend issue vs normal "not authenticated" + * Classify an error into an {@link AuthErrorType} using the centralised + * {@link parseAuthError} utility. + * + * Defaults unrecognised `Error` instances to `"backend"` rather than `null` + * so that unexpected failures surface a "having trouble connecting" banner + * instead of silently logging the user out. */ -function isBackendError(error: unknown): { isBackendDown: boolean; errorType: AuthErrorType } { - // Network errors (fetch failed, DNS, connection refused, etc.) - if (error instanceof TypeError && error.message.includes("fetch")) { - return { isBackendDown: true, errorType: "network" }; - } - - // Check for specific error messages that indicate backend issues - if (error instanceof Error) { - const message = error.message.toLowerCase(); - - // Network-level errors - if ( - message.includes("network") || - message.includes("failed to fetch") || - message.includes("connection refused") || - message.includes("econnrefused") || - message.includes("timeout") - ) { - return { isBackendDown: true, errorType: "network" }; - } - - // Backend errors (5xx status codes typically result in these messages) - if ( - message.includes("internal server error") || - message.includes("service unavailable") || - message.includes("bad gateway") || - message.includes("gateway timeout") - ) { - return { isBackendDown: true, errorType: "backend" }; - } - } - - // Normal auth errors (401, 403, etc.) - user is just not logged in - return { isBackendDown: false, errorType: null }; +function classifyAuthError(error: unknown): AuthErrorType { + const parsed = parseAuthError(error); + if (parsed.code === "network_error") return "network"; + if (parsed.code === "server_error") return "backend"; + // For unrecognised errors, default to "backend" rather than null + // (safer to show "having trouble connecting" than silently log out) + if (error instanceof Error) return "backend"; + return null; } /** - * Log auth errors in development mode + * Log auth errors — always logs, including production. + * Auth failures are operational issues, not debug noise. */ function logAuthError(message: string, error: unknown): void { - if (process.env.NODE_ENV === "development") { - console.error(`[Auth] ${message}:`, error); - } + console.error(`[Auth] ${message}:`, error); } export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element { @@ -99,23 +78,17 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E setAuthError(null); // Track session expiry timestamp - if (session.session?.expiresAt) { - expiresAtRef.current = new Date(session.session.expiresAt); - } + expiresAtRef.current = new Date(session.session.expiresAt); // Reset expiring state on successful session check setSessionExpiring(false); } catch (error) { - const { isBackendDown, errorType } = isBackendError(error); + const errorType = classifyAuthError(error); - if (isBackendDown) { - // Backend/network issue - log and expose error to UI + if (errorType) { logAuthError("Session check failed due to backend/network issue", error); - setAuthError(errorType); - } else { - // Normal "not authenticated" state - no logging needed - setAuthError(null); } + setAuthError(errorType); setUser(null); expiresAtRef.current = null; @@ -130,7 +103,7 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E await apiPost("/auth/sign-out"); } catch (error) { logAuthError("Sign out request did not complete", error); - setAuthError("network"); + setAuthError(classifyAuthError(error) ?? "backend"); } finally { setUser(null); expiresAtRef.current = null; @@ -161,11 +134,12 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E const minutes = Math.ceil(remainingMs / 60_000); if (minutes <= 0) { - // Session has expired + // Session has expired — set explicit state so the UI can react setUser(null); setSessionExpiring(false); setSessionMinutesRemaining(0); expiresAtRef.current = null; + setAuthError("session_expired"); } else if (minutes <= SESSION_EXPIRY_WARNING_MINUTES) { setSessionExpiring(true); setSessionMinutesRemaining(minutes); diff --git a/apps/web/src/lib/auth/auth-errors.ts b/apps/web/src/lib/auth/auth-errors.ts index 36378b3..bfc143a 100644 --- a/apps/web/src/lib/auth/auth-errors.ts +++ b/apps/web/src/lib/auth/auth-errors.ts @@ -169,5 +169,5 @@ export function parseAuthError(error: unknown): ParsedAuthError { * Returns the `unknown` message for any unrecognised code. */ export function getErrorMessage(code: AuthErrorCode): string { - return ERROR_MESSAGES[code] ?? ERROR_MESSAGES.unknown; + return ERROR_MESSAGES[code]; } diff --git a/apps/web/src/lib/auth/fetch-with-retry.test.ts b/apps/web/src/lib/auth/fetch-with-retry.test.ts index 5e46bb2..72c1d31 100644 --- a/apps/web/src/lib/auth/fetch-with-retry.test.ts +++ b/apps/web/src/lib/auth/fetch-with-retry.test.ts @@ -105,7 +105,7 @@ describe("fetchWithRetry", (): void => { fetchWithRetry("https://api.example.com/auth/config", undefined, { maxRetries: 3, baseDelayMs: 1000, - }), + }) ).rejects.toThrow("Failed to fetch"); // 1 initial + 3 retries = 4 total attempts @@ -149,15 +149,13 @@ describe("fetchWithRetry", (): void => { it("should respect custom maxRetries option", async (): Promise => { const networkError = new TypeError("Failed to fetch"); - vi.mocked(global.fetch) - .mockRejectedValueOnce(networkError) - .mockRejectedValueOnce(networkError); + vi.mocked(global.fetch).mockRejectedValueOnce(networkError).mockRejectedValueOnce(networkError); await expect( fetchWithRetry("https://api.example.com/auth/config", undefined, { maxRetries: 1, baseDelayMs: 50, - }), + }) ).rejects.toThrow("Failed to fetch"); // 1 initial + 1 retry = 2 total attempts @@ -202,7 +200,7 @@ describe("fetchWithRetry", (): void => { }); it("should log retry attempts in all environments", async (): Promise => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {}); + const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); const okResponse = mockResponse(200); vi.mocked(global.fetch) @@ -212,28 +210,24 @@ describe("fetchWithRetry", (): void => { await fetchWithRetry("https://api.example.com/auth/config"); expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("[Auth] Retry 1/3"), - ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("[Auth] Retry 1/3")); warnSpy.mockRestore(); }); it("should log retry attempts for HTTP errors", async (): Promise => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {}); + const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); const serverError = mockResponse(500); const okResponse = mockResponse(200); - vi.mocked(global.fetch) - .mockResolvedValueOnce(serverError) - .mockResolvedValueOnce(okResponse); + vi.mocked(global.fetch).mockResolvedValueOnce(serverError).mockResolvedValueOnce(okResponse); await fetchWithRetry("https://api.example.com/auth/config"); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("[Auth] Retry 1/3 after HTTP 500"), + expect.stringContaining("[Auth] Retry 1/3 after HTTP 500") ); warnSpy.mockRestore(); @@ -253,7 +247,7 @@ describe("fetchWithRetry", (): void => { expect(global.fetch).toHaveBeenCalledWith( "https://api.example.com/auth/config", - requestOptions, + requestOptions ); }); @@ -262,9 +256,9 @@ describe("fetchWithRetry", (): void => { const nonRetryableError = new Error("Unauthorized"); vi.mocked(global.fetch).mockRejectedValueOnce(nonRetryableError); - await expect( - fetchWithRetry("https://api.example.com/auth/config"), - ).rejects.toThrow("Unauthorized"); + await expect(fetchWithRetry("https://api.example.com/auth/config")).rejects.toThrow( + "Unauthorized" + ); expect(global.fetch).toHaveBeenCalledTimes(1); expect(sleepMock).not.toHaveBeenCalled(); diff --git a/apps/web/src/lib/auth/fetch-with-retry.ts b/apps/web/src/lib/auth/fetch-with-retry.ts index 1ef6445..3afaa2e 100644 --- a/apps/web/src/lib/auth/fetch-with-retry.ts +++ b/apps/web/src/lib/auth/fetch-with-retry.ts @@ -52,7 +52,7 @@ function computeDelay(attempt: number, baseDelayMs: number, backoffFactor: numbe export async function fetchWithRetry( url: string, options?: RequestInit, - retryOptions?: RetryOptions, + retryOptions?: RetryOptions ): Promise { const maxRetries = retryOptions?.maxRetries ?? DEFAULT_MAX_RETRIES; const baseDelayMs = retryOptions?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS; @@ -80,7 +80,9 @@ export async function fetchWithRetry( lastResponse = response; const delay = computeDelay(attempt, baseDelayMs, backoffFactor); - console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`); + console.warn( + `[Auth] Retry ${String(attempt + 1)}/${String(maxRetries)} after HTTP ${String(response.status)}, waiting ${String(delay)}ms...` + ); await sleep(delay); } catch (error: unknown) { @@ -94,7 +96,9 @@ export async function fetchWithRetry( lastError = error; const delay = computeDelay(attempt, baseDelayMs, backoffFactor); - console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`); + console.warn( + `[Auth] Retry ${String(attempt + 1)}/${String(maxRetries)} after ${parsed.code}, waiting ${String(delay)}ms...` + ); await sleep(delay); } @@ -102,7 +106,10 @@ export async function fetchWithRetry( // Should not be reached due to the loop logic, but satisfy TypeScript if (lastError) { - throw lastError; + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("fetchWithRetry: retries exhausted after non-Error failure"); } if (lastResponse) { return lastResponse;