kaboot/tests/hooks/useAuthenticatedFetch.test.tsx

398 lines
11 KiB
TypeScript

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
});
const { result } = renderHook(() => useAuthenticatedFetch());
let promise1: Promise<Response>;
let promise2: Promise<Response>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});
});