277 lines
7.6 KiB
JavaScript
277 lines
7.6 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { createApiClient } from '@/utilities/api-client';
|
|
|
|
describe('api-client', () => {
|
|
let fetchMock;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn();
|
|
global.fetch = fetchMock;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('createApiClient', () => {
|
|
it('should create client with default config', () => {
|
|
const client = createApiClient();
|
|
expect(client).toHaveProperty('get');
|
|
expect(client).toHaveProperty('post');
|
|
expect(client).toHaveProperty('put');
|
|
expect(client).toHaveProperty('patch');
|
|
expect(client).toHaveProperty('delete');
|
|
});
|
|
|
|
it('should merge baseURL with request path', async () => {
|
|
const client = createApiClient({ baseURL: '/api' });
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({ success: true })
|
|
});
|
|
|
|
await client.get('/users');
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/users',
|
|
expect.objectContaining({ method: 'GET' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('GET requests', () => {
|
|
it('should make successful GET request', async () => {
|
|
const client = createApiClient();
|
|
const mockData = { id: 1, name: 'Test' };
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => mockData
|
|
});
|
|
|
|
const result = await client.get('/test');
|
|
|
|
expect(result).toEqual(mockData);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/test',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json'
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle non-JSON responses', async () => {
|
|
const client = createApiClient();
|
|
const textResponse = 'Plain text response';
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'text/plain' }),
|
|
text: async () => textResponse
|
|
});
|
|
|
|
const result = await client.get('/text');
|
|
|
|
expect(result).toBe(textResponse);
|
|
});
|
|
});
|
|
|
|
describe('POST requests', () => {
|
|
it('should make POST request with data', async () => {
|
|
const client = createApiClient();
|
|
const postData = { name: 'Test' };
|
|
const mockResponse = { id: 1, ...postData };
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => mockResponse
|
|
});
|
|
|
|
const result = await client.post('/users', postData);
|
|
|
|
expect(result).toEqual(mockResponse);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/users',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify(postData)
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should throw error on HTTP error status', async () => {
|
|
const client = createApiClient();
|
|
const errorResponse = { message: 'Not found' };
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => errorResponse
|
|
});
|
|
|
|
await expect(client.get('/missing')).rejects.toThrow('HTTP 404');
|
|
});
|
|
|
|
it('should not retry on 4xx errors', async () => {
|
|
const client = createApiClient({ maxRetries: 3 });
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
headers: new Headers(),
|
|
json: async () => ({})
|
|
});
|
|
|
|
await expect(client.get('/bad')).rejects.toThrow();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should retry on 5xx errors', async () => {
|
|
vi.useFakeTimers();
|
|
const client = createApiClient({ maxRetries: 2, retryDelay: 100 });
|
|
|
|
fetchMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
headers: new Headers(),
|
|
json: async () => ({})
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
headers: new Headers(),
|
|
json: async () => ({})
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({ success: true })
|
|
});
|
|
|
|
const promise = client.get('/retry');
|
|
|
|
// Advance timers for retries
|
|
await vi.runAllTimersAsync();
|
|
|
|
const result = await promise;
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('request deduplication', () => {
|
|
it('should deduplicate identical concurrent requests', async () => {
|
|
const client = createApiClient();
|
|
const mockData = { data: 'test' };
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => mockData
|
|
});
|
|
|
|
// Make two identical concurrent requests
|
|
const [result1, result2] = await Promise.all([
|
|
client.get('/test'),
|
|
client.get('/test')
|
|
]);
|
|
|
|
expect(result1).toEqual(mockData);
|
|
expect(result2).toEqual(mockData);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one actual fetch
|
|
});
|
|
|
|
it('should allow disabling deduplication', async () => {
|
|
const client = createApiClient();
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({ data: 'test' })
|
|
});
|
|
|
|
await Promise.all([
|
|
client.get('/test', { deduplicate: false }),
|
|
client.get('/test', { deduplicate: false })
|
|
]);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('interceptors', () => {
|
|
it('should call onRequest interceptor', async () => {
|
|
const onRequest = vi.fn((url, options) => options);
|
|
const client = createApiClient({ onRequest });
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({})
|
|
});
|
|
|
|
await client.get('/test');
|
|
|
|
expect(onRequest).toHaveBeenCalledWith(
|
|
'/test',
|
|
expect.objectContaining({ method: 'GET' })
|
|
);
|
|
});
|
|
|
|
it('should call onResponse interceptor', async () => {
|
|
const onResponse = vi.fn();
|
|
const client = createApiClient({ onResponse });
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => ({}),
|
|
clone: function () {
|
|
return this;
|
|
}
|
|
});
|
|
|
|
await client.get('/test');
|
|
|
|
expect(onResponse).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call onError interceptor', async () => {
|
|
vi.useFakeTimers();
|
|
const onError = vi.fn();
|
|
const client = createApiClient({ onError, maxRetries: 0 });
|
|
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Server Error',
|
|
headers: new Headers(),
|
|
json: async () => ({})
|
|
});
|
|
|
|
await expect(client.get('/error')).rejects.toThrow();
|
|
expect(onError).toHaveBeenCalled();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
});
|