mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Add tests for apiCall function
This commit is contained in:
580
app/src/api/api.test.ts
Normal file
580
app/src/api/api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user