395 lines
11 KiB
TypeScript
395 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,
|
|
}));
|
|
|
|
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(
|
|
'http://localhost:3001/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);
|
|
});
|
|
});
|
|
});
|