283 lines
9.0 KiB
TypeScript
283 lines
9.0 KiB
TypeScript
/**
|
|
* Tests for API client
|
|
*/
|
|
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
test,
|
|
vi,
|
|
} from 'vitest';
|
|
import {
|
|
ApiError,
|
|
api,
|
|
} from './api';
|
|
|
|
describe('api', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('GET requests', () => {
|
|
test('should return data and status on successful request', async () => {
|
|
const mockData = { id: 1, name: 'Test' };
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => mockData,
|
|
} as Response);
|
|
|
|
const result = await api.get<{ id: number; name: string }>('/api/test');
|
|
|
|
expect(result.data).toEqual(mockData);
|
|
expect(result.status).toBe(200);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/test',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('POST requests', () => {
|
|
test('should send JSON body and return response', async () => {
|
|
const requestBody = { name: 'Alice', email: 'alice@example.com' };
|
|
const mockResponse = { id: 1, ...requestBody };
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => mockResponse,
|
|
} as Response);
|
|
|
|
const result = await api.post<{ id: number; name: string; email: string }>(
|
|
'/api/users',
|
|
requestBody,
|
|
);
|
|
|
|
expect(result.data).toEqual(mockResponse);
|
|
expect(result.status).toBe(201);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/users',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('should handle POST with undefined body', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({}),
|
|
} as Response);
|
|
|
|
await api.post('/api/users', undefined);
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/users',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: undefined,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('PUT requests', () => {
|
|
test('should send JSON body and return response', async () => {
|
|
const requestBody = { id: 1, name: 'Updated' };
|
|
const mockResponse = { ...requestBody };
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => mockResponse,
|
|
} as Response);
|
|
|
|
const result = await api.put<{ id: number; name: string }>('/api/users/1', requestBody);
|
|
|
|
expect(result.data).toEqual(mockResponse);
|
|
expect(result.status).toBe(200);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/users/1',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
body: JSON.stringify(requestBody),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('DELETE requests', () => {
|
|
test('should return data and status on successful deletion', async () => {
|
|
const mockData = { message: 'Deleted successfully' };
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => mockData,
|
|
} as Response);
|
|
|
|
const result = await api.delete<{ message: string }>('/api/users/1');
|
|
|
|
expect(result.data).toEqual(mockData);
|
|
expect(result.status).toBe(200);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/users/1',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
test('should throw ApiError on non-OK response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({ error: 'Resource not found' }),
|
|
} as Response);
|
|
|
|
await expect(api.get('/api/not-found')).rejects.toThrow(ApiError);
|
|
});
|
|
|
|
test('should include status code in ApiError', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
json: async () => ({}),
|
|
} as Response);
|
|
|
|
try {
|
|
await api.get('/api/error');
|
|
expect.fail('Should have thrown ApiError');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect((error as ApiError).status).toBe(500);
|
|
}
|
|
});
|
|
|
|
test('should include message in ApiError', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 403,
|
|
statusText: 'Forbidden',
|
|
json: async () => ({}),
|
|
} as Response);
|
|
|
|
try {
|
|
await api.get('/api/forbidden');
|
|
expect.fail('Should have thrown ApiError');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect((error as ApiError).message).toBe('Request failed: Forbidden');
|
|
}
|
|
});
|
|
|
|
test('should include response object in ApiError', async () => {
|
|
const mockResponse = {
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
json: async () => ({}),
|
|
} as Response;
|
|
vi.mocked(fetch).mockResolvedValueOnce(mockResponse);
|
|
|
|
try {
|
|
await api.get('/api/unauthorized');
|
|
expect.fail('Should have thrown ApiError');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect((error as ApiError).response).toBe(mockResponse);
|
|
}
|
|
});
|
|
|
|
test('should have correct error name', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
json: async () => ({}),
|
|
} as Response);
|
|
|
|
try {
|
|
await api.get('/api/bad-request');
|
|
expect.fail('Should have thrown ApiError');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect((error as ApiError).name).toBe('ApiError');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('headers', () => {
|
|
test('should accept custom headers (replaces defaults)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({}),
|
|
} as Response);
|
|
|
|
await api.get('/api/test', {
|
|
headers: { 'X-Custom-Header': 'custom-value' },
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/test',
|
|
expect.objectContaining({
|
|
headers: {
|
|
'X-Custom-Header': 'custom-value',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('should allow overriding default Content-Type', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({}),
|
|
} as Response);
|
|
|
|
await api.get('/api/test', {
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/test',
|
|
expect.objectContaining({
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('empty response handling', () => {
|
|
test('should handle empty JSON response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 204,
|
|
json: async () => null,
|
|
} as Response);
|
|
|
|
const result = await api.get<null>('/api/empty');
|
|
|
|
expect(result.data).toBeNull();
|
|
expect(result.status).toBe(204);
|
|
});
|
|
});
|
|
});
|