import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const mockSigninSilent = vi.fn(); const mockSigninRedirect = vi.fn(); const mockAuth = { user: { access_token: 'valid-token', expires_at: Math.floor(Date.now() / 1000) + 3600, }, isAuthenticated: true, isLoading: false, signinSilent: mockSigninSilent, signinRedirect: mockSigninRedirect, }; vi.mock('react-oidc-context', () => ({ useAuth: () => mockAuth, })); // Get the API URL that the hook will actually use const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; const originalFetch = global.fetch; describe('useAuthenticatedFetch', () => { let useAuthenticatedFetch: typeof import('../../hooks/useAuthenticatedFetch').useAuthenticatedFetch; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); const module = await import('../../hooks/useAuthenticatedFetch'); useAuthenticatedFetch = module.useAuthenticatedFetch; mockAuth.user = { access_token: 'valid-token', expires_at: Math.floor(Date.now() / 1000) + 3600, }; mockAuth.isAuthenticated = true; mockAuth.isLoading = false; global.fetch = vi.fn(); Object.defineProperty(navigator, 'onLine', { value: true, writable: true }); }); afterEach(() => { global.fetch = originalFetch; vi.resetAllMocks(); }); describe('authFetch - happy path', () => { it('makes authenticated request with Bearer token', async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ data: 'test' }), }); const { result } = renderHook(() => useAuthenticatedFetch()); let response; await act(async () => { response = await result.current.authFetch('/api/test'); }); expect(global.fetch).toHaveBeenCalledWith( `${API_URL}/api/test`, expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer valid-token', 'Content-Type': 'application/json', }), }) ); expect(response).toBeDefined(); }); it('uses full URL when provided', async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, }); const { result } = renderHook(() => useAuthenticatedFetch()); await act(async () => { await result.current.authFetch('https://other-api.com/endpoint'); }); expect(global.fetch).toHaveBeenCalledWith( 'https://other-api.com/endpoint', expect.any(Object) ); }); it('passes through additional options', async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, }); const { result } = renderHook(() => useAuthenticatedFetch()); await act(async () => { await result.current.authFetch('/api/test', { method: 'POST', body: JSON.stringify({ data: 'test' }), }); }); expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'POST', body: JSON.stringify({ data: 'test' }), }) ); }); }); describe('token expiration handling', () => { it('refreshes token silently when expired', async () => { mockAuth.user = { access_token: 'expired-token', expires_at: Math.floor(Date.now() / 1000) - 100, }; mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' }); (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, }); const { result } = renderHook(() => useAuthenticatedFetch()); await act(async () => { await result.current.authFetch('/api/test'); }); expect(mockSigninSilent).toHaveBeenCalled(); expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer new-token', }), }) ); }); it('refreshes token with buffer before actual expiration', async () => { mockAuth.user = { access_token: 'almost-expired-token', expires_at: Math.floor(Date.now() / 1000) + 30, }; mockSigninSilent.mockResolvedValueOnce({ access_token: 'refreshed-token' }); (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, }); const { result } = renderHook(() => useAuthenticatedFetch()); await act(async () => { await result.current.authFetch('/api/test'); }); expect(mockSigninSilent).toHaveBeenCalled(); }); }); describe('redirect scenarios', () => { it('throws error when silent refresh fails on expired token', async () => { mockAuth.user = { access_token: 'expired-token', expires_at: Math.floor(Date.now() / 1000) - 100, }; mockSigninSilent.mockResolvedValueOnce(null); const { result } = renderHook(() => useAuthenticatedFetch()); await expect( act(async () => { await result.current.authFetch('/api/test'); }) ).rejects.toThrow('Session expired, redirecting to login'); }); it('throws error when 401 and refresh fails', async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 401, }); mockSigninSilent.mockResolvedValueOnce(null); const { result } = renderHook(() => useAuthenticatedFetch()); await expect( act(async () => { await result.current.authFetch('/api/test'); }) ).rejects.toThrow('Session expired, redirecting to login'); }); }); describe('401 handling', () => { it('retries with refreshed token on 401', async () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: false, status: 401, }) .mockResolvedValueOnce({ ok: true, status: 200, }); mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' }); const { result } = renderHook(() => useAuthenticatedFetch()); await act(async () => { await result.current.authFetch('/api/test'); }); expect(mockSigninSilent).toHaveBeenCalled(); expect(global.fetch).toHaveBeenCalledTimes(2); }); }); describe('error handling', () => { it('throws on network error', async () => { (global.fetch as ReturnType).mockRejectedValueOnce(new Error('Network failed')); const { result } = renderHook(() => useAuthenticatedFetch()); await expect( act(async () => { await result.current.authFetch('/api/test'); }) ).rejects.toThrow('Network error. Please try again.'); }); it('throws when offline before request', async () => { Object.defineProperty(navigator, 'onLine', { value: false }); const { result } = renderHook(() => useAuthenticatedFetch()); await expect( act(async () => { await result.current.authFetch('/api/test'); }) ).rejects.toThrow('You appear to be offline. Please check your connection.'); }); it('throws server error for 5xx responses', async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useAuthenticatedFetch()); await expect( act(async () => { await result.current.authFetch('/api/test'); }) ).rejects.toThrow('Server error. Please try again later.'); }); it('throws when not authenticated', async () => { mockAuth.user = null; const { result } = renderHook(() => useAuthenticatedFetch()); await expect( act(async () => { await result.current.authFetch('/api/test'); }) ).rejects.toThrow('Not authenticated'); }); }); describe('concurrent requests', () => { it('reuses pending silent refresh for concurrent requests', async () => { mockAuth.user = { access_token: 'expired-token', expires_at: Math.floor(Date.now() / 1000) - 100, }; let resolveRefresh: (value: unknown) => void; const refreshPromise = new Promise((resolve) => { resolveRefresh = resolve; }); mockSigninSilent.mockReturnValue(refreshPromise); (global.fetch as ReturnType).mockResolvedValue({ ok: true, status: 200, }); const { result } = renderHook(() => useAuthenticatedFetch()); let promise1: Promise; let promise2: Promise; act(() => { promise1 = result.current.authFetch('/api/test1'); promise2 = result.current.authFetch('/api/test2'); }); await act(async () => { resolveRefresh!({ access_token: 'new-token' }); }); await act(async () => { await promise1!; await promise2!; }); expect(mockSigninSilent).toHaveBeenCalledTimes(1); }); }); describe('isAuthenticated and user properties', () => { it('exposes isAuthenticated from auth context', () => { mockAuth.isAuthenticated = true; const { result } = renderHook(() => useAuthenticatedFetch()); expect(result.current.isAuthenticated).toBe(true); }); it('exposes isLoading from auth context', () => { mockAuth.isLoading = true; const { result } = renderHook(() => useAuthenticatedFetch()); expect(result.current.isLoading).toBe(true); }); it('exposes user from auth context', () => { const { result } = renderHook(() => useAuthenticatedFetch()); expect(result.current.user).toEqual(mockAuth.user); }); }); describe('edge cases', () => { it('handles missing expires_at gracefully', async () => { mockAuth.user = { access_token: 'token-without-expiry', expires_at: undefined, }; mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' }); (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, }); const { result } = renderHook(() => useAuthenticatedFetch()); await act(async () => { await result.current.authFetch('/api/test'); }); expect(mockSigninSilent).toHaveBeenCalled(); }); it('handles fetch throwing TypeError for network issues', async () => { const typeError = new TypeError('Failed to fetch'); (global.fetch as ReturnType).mockRejectedValueOnce(typeError); const { result } = renderHook(() => useAuthenticatedFetch()); await expect( act(async () => { await result.current.authFetch('/api/test'); }) ).rejects.toThrow('Network error. Please try again.'); }); it('returns response for 4xx errors (non-401)', async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found', }); const { result } = renderHook(() => useAuthenticatedFetch()); let response; await act(async () => { response = await result.current.authFetch('/api/test'); }); expect(response.status).toBe(404); }); }); });