refactor(shared): update utilities, API layer, and types
This commit is contained in:
282
src/shared/api/api.test.ts
Normal file
282
src/shared/api/api.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,50 @@
|
||||
/**
|
||||
* HTTP API client with error handling
|
||||
*
|
||||
* Provides a typed wrapper around fetch for JSON APIs.
|
||||
* Automatically handles JSON serialization and error responses.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { api } from '$shared/api';
|
||||
*
|
||||
* // GET request
|
||||
* const users = await api.get<User[]>('/api/users');
|
||||
*
|
||||
* // POST request
|
||||
* const newUser = await api.post<User>('/api/users', { name: 'Alice' });
|
||||
*
|
||||
* // Error handling
|
||||
* try {
|
||||
* const data = await api.get('/api/data');
|
||||
* } catch (error) {
|
||||
* if (error instanceof ApiError) {
|
||||
* console.error(error.status, error.message);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { ApiResponse } from '$shared/types/common';
|
||||
|
||||
/**
|
||||
* Custom error class for API failures
|
||||
*
|
||||
* Includes HTTP status code and the original Response object
|
||||
* for debugging and error handling.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
/**
|
||||
* Creates a new API error
|
||||
* @param status - HTTP status code
|
||||
* @param message - Error message
|
||||
* @param response - Original fetch Response object
|
||||
*/
|
||||
constructor(
|
||||
/** HTTP status code */
|
||||
public status: number,
|
||||
message: string,
|
||||
/** Original Response object for inspection */
|
||||
public response?: Response,
|
||||
) {
|
||||
super(message);
|
||||
@@ -11,6 +52,16 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal request handler
|
||||
*
|
||||
* Performs fetch with JSON headers and throws ApiError on failure.
|
||||
*
|
||||
* @param url - Request URL
|
||||
* @param options - Fetch options (method, headers, body, etc.)
|
||||
* @returns Response data and status code
|
||||
* @throws ApiError when response is not OK
|
||||
*/
|
||||
async function request<T>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
@@ -39,9 +90,28 @@ async function request<T>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API client methods
|
||||
*
|
||||
* Provides typed methods for common HTTP verbs.
|
||||
* All methods return ApiResponse with data and status.
|
||||
*/
|
||||
export const api = {
|
||||
/**
|
||||
* Performs a GET request
|
||||
* @param url - Request URL
|
||||
* @param options - Additional fetch options
|
||||
* @returns Response data
|
||||
*/
|
||||
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
|
||||
|
||||
/**
|
||||
* Performs a POST request with JSON body
|
||||
* @param url - Request URL
|
||||
* @param body - Request body (will be JSON stringified)
|
||||
* @param options - Additional fetch options
|
||||
* @returns Response data
|
||||
*/
|
||||
post: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
@@ -49,6 +119,13 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Performs a PUT request with JSON body
|
||||
* @param url - Request URL
|
||||
* @param body - Request body (will be JSON stringified)
|
||||
* @param options - Additional fetch options
|
||||
* @returns Response data
|
||||
*/
|
||||
put: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
@@ -56,5 +133,11 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Performs a DELETE request
|
||||
* @param url - Request URL
|
||||
* @param options - Additional fetch options
|
||||
* @returns Response data
|
||||
*/
|
||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
|
||||
/**
|
||||
* Query client instance
|
||||
* TanStack Query client instance
|
||||
*
|
||||
* Configured for optimal caching and refetching behavior.
|
||||
* Used by all font stores for data fetching and caching.
|
||||
*
|
||||
* Cache behavior:
|
||||
* - Data stays fresh for 5 minutes (staleTime)
|
||||
* - Unused data is garbage collected after 10 minutes (gcTime)
|
||||
* - No refetch on window focus (reduces unnecessary network requests)
|
||||
* - 3 retries with exponential backoff on failure
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/**
|
||||
* Default staleTime: 5 minutes
|
||||
*/
|
||||
/** Data remains fresh for 5 minutes after fetch */
|
||||
staleTime: 5 * 60 * 1000,
|
||||
/**
|
||||
* Default gcTime: 10 minutes
|
||||
*/
|
||||
/** Unused cache entries are removed after 10 minutes */
|
||||
gcTime: 10 * 60 * 1000,
|
||||
/** Don't refetch when window regains focus */
|
||||
refetchOnWindowFocus: false,
|
||||
/** Refetch on mount if data is stale */
|
||||
refetchOnMount: true,
|
||||
/** Retry failed requests up to 3 times */
|
||||
retry: 3,
|
||||
/**
|
||||
* Exponential backoff
|
||||
* Exponential backoff for retries
|
||||
* 1s, 2s, 4s, 8s... capped at 30s
|
||||
*/
|
||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user