From 622e35e46829aa95d310f9981b11723a896cad97 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Wed, 17 Jun 2026 21:33:03 +0100 Subject: [PATCH] refactor(api-client): transparent 401 -> refresh -> retry-once Centralizes authenticated fetches, refreshes the session on 401 and retries once, falling back to the session-expiry path on failure. --- app/api/auth/refresh/route.ts | 16 +++ app/dashboard/transaction-history/page.tsx | 7 +- app/settings/page.tsx | 6 +- lib/client/apiClient.ts | 14 ++- lib/client/sessionHandler.ts | 39 +++++++ lib/hooks/useFormAction.ts | 5 +- package-lock.json | 2 + tests/session/apiClient.test.ts | 128 +++++++++++++++++++++ 8 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 app/api/auth/refresh/route.ts create mode 100644 tests/session/apiClient.test.ts diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..cd714dd --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,16 @@ +import { getSessionWithRefresh } from '@/lib/session'; + +export const dynamic = 'force-dynamic'; + +export async function POST() { + const session = await getSessionWithRefresh(); + + if (!session?.address) { + return Response.json( + { error: 'Unauthorized', message: 'Session expired' }, + { status: 401 } + ); + } + + return Response.json({ success: true, address: session.address }); +} diff --git a/app/dashboard/transaction-history/page.tsx b/app/dashboard/transaction-history/page.tsx index 6b261af..8778bd7 100644 --- a/app/dashboard/transaction-history/page.tsx +++ b/app/dashboard/transaction-history/page.tsx @@ -8,6 +8,7 @@ import Button from "./components/transaction-history-button"; import { Download, FilterIcon, Loader2 } from "lucide-react"; import { TransactionItem } from '@/lib/remittance/horizon'; import { useClientTranslator } from '@/lib/i18n/client'; +import { apiClient } from '@/lib/client/apiClient'; const TransactionHistoryPage = () => { const { t } = useClientTranslator(); @@ -33,7 +34,9 @@ const TransactionHistoryPage = () => { params.append('status', statusFilter); } - const response = await fetch(`/api/v1/remittance/history?${params}`); + const response = await apiClient.get(`/api/v1/remittance/history?${params}`); + if (!response) return; // Handled by session expiry flow + if (!response.ok) { throw new Error(t('transactionHistory.alerts.fetchFailed')); } @@ -53,7 +56,7 @@ const TransactionHistoryPage = () => { } finally { setLoading(false); } - }, [statusFilter]); + }, [statusFilter, t]); useEffect(() => { fetchTransactions(undefined, true); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d60f4d0..86dd7ac 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -16,7 +16,7 @@ import { Smartphone, } from "lucide-react"; import { useDensity } from "@/lib/context/DensityContext"; - +import { apiClient } from "@/lib/client/apiClient"; const SECTIONS = [ { id: "profile", label: "Profile", icon: User }, @@ -224,7 +224,9 @@ function InsuranceReminderPreview() { async function loadReminders() { try { - const response = await fetch("/api/insurance/reminders"); + const response = await apiClient.get("/api/insurance/reminders"); + if (!response) return; // Handled by session expiry flow + if (!active) return; if (response.status === 401 || response.status === 403) { diff --git a/lib/client/apiClient.ts b/lib/client/apiClient.ts index de2b52c..1bc4e86 100644 --- a/lib/client/apiClient.ts +++ b/lib/client/apiClient.ts @@ -21,7 +21,8 @@ import { sessionHandler } from './sessionHandler'; export interface ApiClientOptions extends RequestInit { - // Additional options can be added here + /** Internal flag to prevent infinite retry loops */ + _isRetry?: boolean; } /** @@ -36,7 +37,16 @@ async function request(url: string, options?: ApiClientOptions): Promise; + /** * Clear local authentication state * Removes stored wallet address and connection status @@ -49,6 +55,9 @@ export interface SessionHandler { clearAuthState(): void; } +// Store the active refresh promise to deduplicate concurrent requests +let refreshPromise: Promise | null = null; + /** * Check if a response indicates session expiry * @param response - The fetch Response object to check @@ -70,6 +79,35 @@ async function isSessionExpired(response: Response): Promise { } } +/** + * Attempt to refresh the current session by calling the refresh endpoint + * Deduplicates concurrent calls to ensure only one refresh request is made at a time. + * @returns true if session was refreshed, false otherwise + */ +async function refreshSession(): Promise { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + return response.ok; + } catch { + return false; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +} + /** * Dispatch session-expiring warning event * Call this when the backend indicates the session is about to expire @@ -138,6 +176,7 @@ function handleSessionExpiry(intendedPath?: string): void { */ export const sessionHandler: SessionHandler = { isSessionExpired, + refreshSession, handleSessionExpiry, dispatchSessionExpiring, clearAuthState, diff --git a/lib/hooks/useFormAction.ts b/lib/hooks/useFormAction.ts index ac37154..a3e7822 100644 --- a/lib/hooks/useFormAction.ts +++ b/lib/hooks/useFormAction.ts @@ -3,6 +3,7 @@ import { useState, useTransition, useCallback } from "react"; import { ActionState } from "@/lib/auth/middleware"; +import { apiClient } from "@/lib/client/apiClient"; // Merge the base with whatever extra fields your specific route returns @@ -17,7 +18,9 @@ export function useFormAction( (formData: FormData) => { startTransition(async () => { try { - const res = await fetch(url, { method, body: formData }); + const res = await apiClient.request(url, { method, body: formData }); + if (!res) return; // Handled by session expiry flow + const data: T = await res.json(); setState(data); } catch { diff --git a/package-lock.json b/package-lock.json index d95e5d8..467aee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10865,6 +10865,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -14227,6 +14228,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/tests/session/apiClient.test.ts b/tests/session/apiClient.test.ts new file mode 100644 index 0000000..6014353 --- /dev/null +++ b/tests/session/apiClient.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { apiClient } from '../../lib/client/apiClient'; +import { sessionHandler } from '../../lib/client/sessionHandler'; + +// Mock the global fetch +const originalFetch = global.fetch; + +// Mock window object +const originalWindow = global.window; + +describe('apiClient', () => { + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Create a mock fetch that we can track + global.fetch = vi.fn(); + + // Mock window + global.window = { + location: { pathname: '/current-path' }, + dispatchEvent: vi.fn(), + } as any; + + // Mock the sessionHandler methods + vi.spyOn(sessionHandler, 'isSessionExpired').mockResolvedValue(false); + vi.spyOn(sessionHandler, 'refreshSession').mockResolvedValue(true); + vi.spyOn(sessionHandler, 'handleSessionExpiry').mockImplementation(() => {}); + }); + + afterEach(() => { + global.fetch = originalFetch; + global.window = originalWindow; + }); + + it('should return response normally on 200 OK', async () => { + const mockResponse = new Response('ok', { status: 200 }); + vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse); + + const response = await apiClient.get('/api/test'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith('/api/test', { method: 'GET' }); + expect(response).toBe(mockResponse); + expect(sessionHandler.isSessionExpired).toHaveBeenCalledWith(mockResponse); + expect(sessionHandler.refreshSession).not.toHaveBeenCalled(); + }); + + it('should refresh and retry once on 401 session expiry', async () => { + const mock401Response = new Response('{"message":"Session expired"}', { status: 401 }); + const mock200Response = new Response('ok', { status: 200 }); + + // First fetch returns 401, second returns 200 + vi.mocked(global.fetch) + .mockResolvedValueOnce(mock401Response) + .mockResolvedValueOnce(mock200Response); + + // Mock isSessionExpired to return true for the 401 response and false for the 200 response + vi.mocked(sessionHandler.isSessionExpired).mockImplementation(async (res) => res.status === 401); + vi.mocked(sessionHandler.refreshSession).mockResolvedValueOnce(true); + + const response = await apiClient.get('/api/test'); + + // Should have called fetch twice (initial + retry) + expect(global.fetch).toHaveBeenCalledTimes(2); + // Should have attempted refresh once + expect(sessionHandler.refreshSession).toHaveBeenCalledTimes(1); + // Should have returned the successful response + expect(response).toBe(mock200Response); + // Should not have triggered expiry flow + expect(sessionHandler.handleSessionExpiry).not.toHaveBeenCalled(); + }); + + it('should fallback to expiry flow if refresh fails', async () => { + const mock401Response = new Response('{"message":"Session expired"}', { status: 401 }); + + vi.mocked(global.fetch).mockResolvedValueOnce(mock401Response); + vi.mocked(sessionHandler.isSessionExpired).mockResolvedValueOnce(true); + // Refresh fails + vi.mocked(sessionHandler.refreshSession).mockResolvedValueOnce(false); + + const response = await apiClient.get('/api/test'); + + // Should have called fetch once (no retry since refresh failed) + expect(global.fetch).toHaveBeenCalledTimes(1); + // Should have attempted refresh + expect(sessionHandler.refreshSession).toHaveBeenCalledTimes(1); + // Should have triggered expiry flow + expect(sessionHandler.handleSessionExpiry).toHaveBeenCalledTimes(1); + // Returns null on expiry + expect(response).toBeNull(); + }); + + it('should not retry on 403 Forbidden', async () => { + const mock403Response = new Response('Forbidden', { status: 403 }); + vi.mocked(global.fetch).mockResolvedValueOnce(mock403Response); + + // 403 is not a session expiry + vi.mocked(sessionHandler.isSessionExpired).mockResolvedValueOnce(false); + + const response = await apiClient.get('/api/test'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(sessionHandler.refreshSession).not.toHaveBeenCalled(); + expect(response).toBe(mock403Response); + }); + + it('should fallback to expiry flow if retry also returns 401', async () => { + const mock401Response = new Response('{"message":"Session expired"}', { status: 401 }); + + // Both initial and retry return 401 + vi.mocked(global.fetch) + .mockResolvedValueOnce(mock401Response) + .mockResolvedValueOnce(mock401Response); + + vi.mocked(sessionHandler.isSessionExpired).mockResolvedValue(true); + // Refresh succeeds + vi.mocked(sessionHandler.refreshSession).mockResolvedValueOnce(true); + + const response = await apiClient.get('/api/test'); + + // Called fetch twice + expect(global.fetch).toHaveBeenCalledTimes(2); + // Expiry flow triggered because retry failed + expect(sessionHandler.handleSessionExpiry).toHaveBeenCalledTimes(1); + expect(response).toBeNull(); + }); +});