Add tests for apiCall function

This commit is contained in:
2025-05-29 11:57:50 +02:00
parent 5ed3e96350
commit 2211f85193

580
app/src/api/api.test.ts Normal file
View File

@@ -0,0 +1,580 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { apiCall } from './api';
// Mock the auth module - move this before any constants
vi.mock('./auth', () => {
return {
refreshToken: vi.fn(),
};
});
// Get the mocked function after vi.mock
const mockRefreshToken = vi.mocked(await import('./auth')).refreshToken;
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
// Helper to create mock Response objects
const createMockResponse = (
status: number,
body: unknown = {},
ok?: boolean
): Response => {
const response = {
status,
ok: ok !== undefined ? ok : status >= 200 && status < 300,
json: vi.fn().mockResolvedValue(body),
text: vi
.fn()
.mockResolvedValue(
typeof body === 'string' ? body : JSON.stringify(body)
),
} as unknown as Response;
return response;
};
// Helper to set document.cookie
const setCookie = (name: string, value: string) => {
Object.defineProperty(document, 'cookie', {
writable: true,
value: `${name}=${encodeURIComponent(value)}`,
configurable: true,
});
};
describe('apiCall', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear cookies
Object.defineProperty(document, 'cookie', {
writable: true,
value: '',
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('basic functionality', () => {
it('makes a successful GET request', async () => {
const mockResponseData = { success: true };
mockFetch.mockResolvedValue(createMockResponse(200, mockResponseData));
const result = await apiCall('https://api.example.com/test');
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
expect(result.status).toBe(200);
});
it('makes a successful POST request with body', async () => {
const requestBody = { name: 'test' };
const mockResponseData = { id: 1, name: 'test' };
mockFetch.mockResolvedValue(createMockResponse(201, mockResponseData));
const result = await apiCall('https://api.example.com/create', {
method: 'POST',
body: JSON.stringify(requestBody),
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
method: 'POST',
body: JSON.stringify(requestBody),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
expect(result.status).toBe(201);
});
it('handles 204 No Content responses', async () => {
mockFetch.mockResolvedValue(createMockResponse(204, null, true));
const result = await apiCall('https://api.example.com/delete', {
method: 'DELETE',
});
expect(result.status).toBe(204);
});
it('preserves custom headers', async () => {
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/test', {
headers: {
'Custom-Header': 'custom-value',
'Content-Type': 'text/plain',
},
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
credentials: 'include',
headers: {
'Content-Type': 'text/plain', // Custom content type should override
'Custom-Header': 'custom-value',
},
});
});
});
describe('CSRF token handling', () => {
it('adds CSRF token to non-GET requests when token exists', async () => {
setCookie('csrf_token', 'test-csrf-token');
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/create', {
method: 'POST',
body: JSON.stringify({ test: 'data' }),
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
method: 'POST',
body: JSON.stringify({ test: 'data' }),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': 'test-csrf-token',
},
});
});
it('omits CSRF token with GET methods', async () => {
setCookie('csrf_token', 'test-token');
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/test', { method: 'GET' });
// Check that CSRF token is not included in headers
const calledOptions = mockFetch.mock.calls?.[0]?.[1] as RequestInit;
expect(calledOptions['headers']).not.toHaveProperty('X-CSRF-Token');
});
it('handles URL-encoded CSRF tokens', async () => {
const encodedToken = 'token%20with%20spaces';
setCookie('csrf_token', encodedToken);
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/create', {
method: 'POST',
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': encodedToken, // We shouldn't expect it to be decoded since our api.ts is not decoding it
},
});
});
it('handles missing CSRF token gracefully', async () => {
// No CSRF token in cookies
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/create', {
method: 'POST',
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
// No X-CSRF-Token header
},
});
});
it('handles multiple cookies and extracts CSRF token correctly', async () => {
Object.defineProperty(document, 'cookie', {
writable: true,
value:
'session_id=abc123; csrf_token=my-csrf-token; other_cookie=value',
configurable: true,
});
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/create', {
method: 'POST',
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': 'my-csrf-token',
},
});
});
it('handles empty CSRF token value', async () => {
setCookie('csrf_token', '');
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/create', {
method: 'POST',
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
// No X-CSRF-Token header when token is empty
},
});
});
});
describe('error handling', () => {
it('throws error for non-2xx status codes', async () => {
const errorResponse = { message: 'Bad Request' };
mockFetch.mockResolvedValue(
createMockResponse(400, errorResponse, false)
);
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
'Bad Request'
);
});
it('throws generic error when no error message in response', async () => {
mockFetch.mockResolvedValue(createMockResponse(500, {}, false));
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
'HTTP error! status: 500'
);
});
it('handles malformed JSON error responses', async () => {
const mockResponse = {
status: 400,
ok: false,
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
} as unknown as Response;
mockFetch.mockResolvedValue(mockResponse);
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
'Invalid JSON'
);
});
it('handles network errors', async () => {
const networkError = new Error('Network error');
mockFetch.mockRejectedValue(networkError);
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
'Network error'
);
});
it('handles timeout errors', async () => {
const timeoutError = new Error('Request timeout');
mockFetch.mockRejectedValue(timeoutError);
await expect(apiCall('https://api.example.com/slow')).rejects.toThrow(
'Request timeout'
);
});
});
describe('authentication and token refresh', () => {
it('handles 401 response by attempting token refresh and retrying', async () => {
const successResponse = createMockResponse(200, { data: 'success' });
mockFetch
.mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails with 401
.mockResolvedValueOnce(successResponse); // Retry succeeds
mockRefreshToken.mockResolvedValue(true);
const result = await apiCall('https://api.example.com/protected');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
expect(result.status).toBe(200);
});
it('throws error when token refresh fails', async () => {
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
mockRefreshToken.mockResolvedValue(false);
await expect(
apiCall('https://api.example.com/protected')
).rejects.toThrow('Authentication failed');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
});
it('does not attempt refresh for auth/refresh endpoint', async () => {
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
await expect(
apiCall('https://api.example.com/auth/refresh')
).rejects.toThrow('Authentication failed');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockRefreshToken).not.toHaveBeenCalled();
});
it('handles successful token refresh but failed retry', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails
.mockResolvedValueOnce(
createMockResponse(403, { message: 'Forbidden' }, false)
); // Retry fails with different error
mockRefreshToken.mockResolvedValue(true);
await expect(
apiCall('https://api.example.com/protected')
).rejects.toThrow('Forbidden');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
});
it('handles token refresh throwing an error', async () => {
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
mockRefreshToken.mockRejectedValue(new Error('Refresh failed'));
await expect(
apiCall('https://api.example.com/protected')
).rejects.toThrow('Refresh failed'); // The test should match the actual error from the mock
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
});
it('preserves original request options in retry', async () => {
const requestBody = { data: 'test' };
const customHeaders = { 'Custom-Header': 'value' };
mockFetch
.mockResolvedValueOnce(createMockResponse(401, {}, false))
.mockResolvedValueOnce(createMockResponse(200, { success: true }));
mockRefreshToken.mockResolvedValue(true);
await apiCall('https://api.example.com/protected', {
method: 'POST',
body: JSON.stringify(requestBody),
headers: customHeaders,
});
// Check that both calls had the same options
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenNthCalledWith(
1,
'https://api.example.com/protected',
{
method: 'POST',
body: JSON.stringify(requestBody),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Custom-Header': 'value',
},
}
);
expect(mockFetch).toHaveBeenNthCalledWith(
2,
'https://api.example.com/protected',
{
method: 'POST',
body: JSON.stringify(requestBody),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Custom-Header': 'value',
},
}
);
});
});
describe('console logging', () => {
it('logs debug information for requests and responses', async () => {
const consoleSpy = vi
.spyOn(console, 'debug')
.mockImplementation(() => {});
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/test');
expect(consoleSpy).toHaveBeenCalledWith(
'Making API call to: https://api.example.com/test'
);
expect(consoleSpy).toHaveBeenCalledWith(
'Response status: 200 for URL: https://api.example.com/test'
);
consoleSpy.mockRestore();
});
it('logs errors when API calls fail', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const networkError = new Error('Network failure');
mockFetch.mockRejectedValue(networkError);
await expect(apiCall('https://api.example.com/error')).rejects.toThrow();
expect(consoleSpy).toHaveBeenCalledWith(
'API call failed: Network failure'
);
consoleSpy.mockRestore();
});
});
describe('request options handling', () => {
it('merges provided options with defaults', async () => {
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/test', {
method: 'PUT',
cache: 'no-cache' as RequestCache,
redirect: 'follow' as RequestRedirect,
});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
method: 'PUT',
cache: 'no-cache',
redirect: 'follow',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
});
it('handles undefined options parameter', async () => {
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/test');
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
});
it('handles empty options object', async () => {
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/test', {});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
});
});
describe('HTTP methods', () => {
it('handles different HTTP methods correctly', async () => {
setCookie('csrf_token', 'test-token');
mockFetch.mockResolvedValue(createMockResponse(200, {}));
const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
for (const method of methods) {
mockFetch.mockClear();
await apiCall('https://api.example.com/test', { method });
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
method,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': 'test-token',
},
});
}
});
it('defaults to GET method when method is omitted', async () => {
setCookie('csrf_token', 'test-token');
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall('https://api.example.com/test', {});
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
method: undefined,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
// No CSRF token for undefined (GET) method
},
});
});
});
describe('edge cases', () => {
it('handles very long URLs', async () => {
const longUrl = 'https://api.example.com/' + 'a'.repeat(2000);
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall(longUrl);
expect(mockFetch).toHaveBeenCalledWith(longUrl, expect.any(Object));
});
it('handles special characters in URL', async () => {
const urlWithSpecialChars =
'https://api.example.com/test?param=value&other=test%20value';
mockFetch.mockResolvedValue(createMockResponse(200, {}));
await apiCall(urlWithSpecialChars);
expect(mockFetch).toHaveBeenCalledWith(
urlWithSpecialChars,
expect.any(Object)
);
});
it('handles null response body', async () => {
const mockResponse = {
status: 200,
ok: true,
json: vi.fn().mockResolvedValue(null),
} as unknown as Response;
mockFetch.mockResolvedValue(mockResponse);
const result = await apiCall('https://api.example.com/test');
expect(result.status).toBe(200);
});
it('handles empty string response body', async () => {
const mockResponse = {
status: 200,
ok: true,
json: vi.fn().mockResolvedValue(''),
} as unknown as Response;
mockFetch.mockResolvedValue(mockResponse);
const result = await apiCall('https://api.example.com/test');
expect(result.status).toBe(200);
});
});
});