- Consolidate API logic: hooks/useApi.ts now uses services/api.ts - Create BaseFormField component to reduce form duplication - Refactor FormField, FormSelect, FormTextarea to use BaseFormField - Add centralized theme utility (utils/theme.ts) for colors/styles - Add comprehensive tests for api, auth.service, useApi hooks, AuthContext - Add tests for theme utility This reduces duplication from 11.45% and improves test coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
277 lines
7.3 KiB
TypeScript
277 lines
7.3 KiB
TypeScript
/**
|
|
* Tests for the API service
|
|
* @jest-environment jsdom
|
|
*/
|
|
|
|
import { api, ApiError } from '@/services/api';
|
|
|
|
// Mock fetch globally
|
|
const mockFetch = jest.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
// Mock helpers
|
|
jest.mock('@/utils/helpers', () => ({
|
|
getToken: jest.fn(),
|
|
}));
|
|
|
|
import { getToken } from '@/utils/helpers';
|
|
|
|
describe('API Service', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
(getToken as jest.Mock).mockReturnValue(null);
|
|
});
|
|
|
|
describe('ApiError class', () => {
|
|
it('should create an error with status and message', () => {
|
|
const error = new ApiError(404, 'Not found');
|
|
expect(error.status).toBe(404);
|
|
expect(error.message).toBe('Not found');
|
|
expect(error.name).toBe('ApiError');
|
|
});
|
|
|
|
it('should create an error with data', () => {
|
|
const errorData = { field: 'email', reason: 'invalid' };
|
|
const error = new ApiError(400, 'Validation error', errorData);
|
|
expect(error.data).toEqual(errorData);
|
|
});
|
|
});
|
|
|
|
describe('api.get', () => {
|
|
it('should make a GET request without token', async () => {
|
|
const mockResponse = { data: 'test' };
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => mockResponse,
|
|
});
|
|
|
|
const result = await api.get('/test');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost:4000/api/test',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
})
|
|
);
|
|
expect(result).toEqual(mockResponse);
|
|
});
|
|
|
|
it('should include Authorization header when token exists', async () => {
|
|
(getToken as jest.Mock).mockReturnValue('test-token');
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({}),
|
|
});
|
|
|
|
await api.get('/test');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer test-token',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle full URLs', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({}),
|
|
});
|
|
|
|
await api.get('http://external.api/endpoint');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://external.api/endpoint',
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('should throw ApiError on non-ok response', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
json: async () => ({ message: 'Invalid token' }),
|
|
});
|
|
|
|
await expect(api.get('/test')).rejects.toThrow(ApiError);
|
|
|
|
// Need a fresh mock for the second assertion
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
json: async () => ({ message: 'Invalid token' }),
|
|
});
|
|
|
|
await expect(api.get('/test')).rejects.toMatchObject({
|
|
status: 401,
|
|
message: 'Invalid token',
|
|
});
|
|
});
|
|
|
|
it('should handle network errors', async () => {
|
|
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
|
|
await expect(api.get('/test')).rejects.toThrow(ApiError);
|
|
|
|
// Fresh mock for the second assertion
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await expect(api.get('/test')).rejects.toMatchObject({
|
|
status: 0,
|
|
message: 'Erreur de connexion au serveur',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('api.post', () => {
|
|
it('should make a POST request with data', async () => {
|
|
const postData = { email: 'test@test.com', password: 'password' };
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ success: true }),
|
|
});
|
|
|
|
await api.post('/auth/login', postData);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost:4000/api/auth/login',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify(postData),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should make a POST request without data', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ success: true }),
|
|
});
|
|
|
|
await api.post('/auth/logout');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: undefined,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('api.put', () => {
|
|
it('should make a PUT request with data', async () => {
|
|
const updateData = { name: 'New Name' };
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ success: true }),
|
|
});
|
|
|
|
await api.put('/users/1', updateData);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost:4000/api/users/1',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
body: JSON.stringify(updateData),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('api.patch', () => {
|
|
it('should make a PATCH request with data', async () => {
|
|
const patchData = { status: 'active' };
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ success: true }),
|
|
});
|
|
|
|
await api.patch('/users/1', patchData);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost:4000/api/users/1',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patchData),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('api.delete', () => {
|
|
it('should make a DELETE request', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ success: true }),
|
|
});
|
|
|
|
await api.delete('/users/1');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost:4000/api/users/1',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Error handling', () => {
|
|
it('should extract error message from response', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
json: async () => ({ message: 'Email already exists' }),
|
|
});
|
|
|
|
try {
|
|
await api.post('/auth/register', {});
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect((error as ApiError).message).toBe('Email already exists');
|
|
}
|
|
});
|
|
|
|
it('should use error field if message is not present', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
json: async () => ({ error: 'Validation failed' }),
|
|
});
|
|
|
|
try {
|
|
await api.post('/auth/register', {});
|
|
} catch (error) {
|
|
expect((error as ApiError).message).toBe('Validation failed');
|
|
}
|
|
});
|
|
|
|
it('should use statusText if JSON parsing fails', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
json: async () => { throw new Error('Invalid JSON'); },
|
|
});
|
|
|
|
try {
|
|
await api.get('/test');
|
|
} catch (error) {
|
|
expect((error as ApiError).message).toBe('Internal Server Error');
|
|
}
|
|
});
|
|
});
|
|
});
|