- 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>
334 lines
9.3 KiB
TypeScript
334 lines
9.3 KiB
TypeScript
/**
|
|
* Tests for the useApi hook
|
|
* @jest-environment jsdom
|
|
*/
|
|
|
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
import { useApi, useFetchData } from '@/hooks/useApi';
|
|
import { api } from '@/services/api';
|
|
|
|
// Mock the api module
|
|
jest.mock('@/services/api', () => ({
|
|
api: {
|
|
get: jest.fn(),
|
|
post: jest.fn(),
|
|
put: jest.fn(),
|
|
patch: jest.fn(),
|
|
delete: jest.fn(),
|
|
},
|
|
ApiError: class ApiError extends Error {
|
|
constructor(public status: number, message: string, public data?: unknown) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
},
|
|
}));
|
|
|
|
// Mock react-hot-toast - define mock inside factory to avoid hoisting issues
|
|
jest.mock('react-hot-toast', () => {
|
|
const mockToast = {
|
|
error: jest.fn(),
|
|
success: jest.fn(),
|
|
};
|
|
return {
|
|
__esModule: true,
|
|
default: mockToast,
|
|
toast: mockToast,
|
|
error: mockToast.error,
|
|
success: mockToast.success,
|
|
};
|
|
});
|
|
|
|
// Import to access the mock for assertions
|
|
import { toast } from 'react-hot-toast';
|
|
|
|
describe('useApi Hook', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('initial state', () => {
|
|
it('should have correct initial state', () => {
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
expect(result.current.data).toBeNull();
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('should execute GET request successfully', async () => {
|
|
const mockData = { id: 1, name: 'Test' };
|
|
(api.get as jest.Mock).mockResolvedValueOnce(mockData);
|
|
|
|
const { result } = renderHook(() => useApi<typeof mockData>());
|
|
|
|
let response: typeof mockData | null = null;
|
|
await act(async () => {
|
|
response = await result.current.execute('get', '/test');
|
|
});
|
|
|
|
expect(api.get).toHaveBeenCalledWith('/test');
|
|
expect(result.current.data).toEqual(mockData);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBeNull();
|
|
expect(response).toEqual(mockData);
|
|
});
|
|
|
|
it('should execute POST request with data', async () => {
|
|
const postData = { email: 'test@test.com' };
|
|
const mockResponse = { success: true };
|
|
(api.post as jest.Mock).mockResolvedValueOnce(mockResponse);
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
await act(async () => {
|
|
await result.current.execute('post', '/auth/login', postData);
|
|
});
|
|
|
|
expect(api.post).toHaveBeenCalledWith('/auth/login', postData);
|
|
expect(result.current.data).toEqual(mockResponse);
|
|
});
|
|
|
|
it('should execute PUT request', async () => {
|
|
const putData = { name: 'Updated' };
|
|
(api.put as jest.Mock).mockResolvedValueOnce({ success: true });
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
await act(async () => {
|
|
await result.current.execute('put', '/users/1', putData);
|
|
});
|
|
|
|
expect(api.put).toHaveBeenCalledWith('/users/1', putData);
|
|
});
|
|
|
|
it('should execute PATCH request', async () => {
|
|
const patchData = { status: 'active' };
|
|
(api.patch as jest.Mock).mockResolvedValueOnce({ success: true });
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
await act(async () => {
|
|
await result.current.execute('patch', '/users/1', patchData);
|
|
});
|
|
|
|
expect(api.patch).toHaveBeenCalledWith('/users/1', patchData);
|
|
});
|
|
|
|
it('should execute DELETE request', async () => {
|
|
(api.delete as jest.Mock).mockResolvedValueOnce({ success: true });
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
await act(async () => {
|
|
await result.current.execute('delete', '/users/1');
|
|
});
|
|
|
|
expect(api.delete).toHaveBeenCalledWith('/users/1');
|
|
});
|
|
|
|
it('should handle errors and show toast', async () => {
|
|
const errorMessage = 'Something went wrong';
|
|
(api.get as jest.Mock).mockRejectedValueOnce(new Error(errorMessage));
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
await act(async () => {
|
|
await result.current.execute('get', '/test');
|
|
});
|
|
|
|
expect(result.current.data).toBeNull();
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe(errorMessage);
|
|
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
|
});
|
|
|
|
it('should return null on error', async () => {
|
|
(api.get as jest.Mock).mockRejectedValueOnce(new Error('Error'));
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
let response: unknown = 'initial';
|
|
await act(async () => {
|
|
response = await result.current.execute('get', '/test');
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
});
|
|
|
|
it('should set loading state during request', async () => {
|
|
let resolvePromise: (value: unknown) => void;
|
|
const promise = new Promise((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
(api.get as jest.Mock).mockReturnValueOnce(promise);
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
act(() => {
|
|
result.current.execute('get', '/test');
|
|
});
|
|
|
|
// Should be loading immediately after calling execute
|
|
expect(result.current.loading).toBe(true);
|
|
|
|
// Resolve the promise
|
|
await act(async () => {
|
|
resolvePromise!({ data: 'test' });
|
|
await promise;
|
|
});
|
|
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('should reset state to initial values', async () => {
|
|
const mockData = { id: 1 };
|
|
(api.get as jest.Mock).mockResolvedValueOnce(mockData);
|
|
|
|
const { result } = renderHook(() => useApi());
|
|
|
|
await act(async () => {
|
|
await result.current.execute('get', '/test');
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockData);
|
|
|
|
act(() => {
|
|
result.current.reset();
|
|
});
|
|
|
|
expect(result.current.data).toBeNull();
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useFetchData Hook', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should have correct initial state before refetch', () => {
|
|
const { result } = renderHook(() => useFetchData('/test'));
|
|
|
|
// Before calling refetch, data should be null and loading should be true
|
|
expect(result.current.data).toBeNull();
|
|
expect(result.current.loading).toBe(true);
|
|
expect(result.current.error).toBeNull();
|
|
expect(typeof result.current.refetch).toBe('function');
|
|
});
|
|
|
|
it('should extract data from response.data', async () => {
|
|
const mockData = { id: 1, name: 'Test' };
|
|
(api.get as jest.Mock).mockResolvedValue({ data: mockData });
|
|
|
|
const { result } = renderHook(() => useFetchData<typeof mockData>('/test'));
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockData);
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
it('should use response directly if no data field', async () => {
|
|
const mockData = { id: 1, name: 'Test' };
|
|
(api.get as jest.Mock).mockResolvedValue(mockData);
|
|
|
|
const { result } = renderHook(() => useFetchData<typeof mockData>('/test'));
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockData);
|
|
});
|
|
|
|
it('should call onSuccess callback', async () => {
|
|
const mockData = { id: 1 };
|
|
const onSuccess = jest.fn();
|
|
(api.get as jest.Mock).mockResolvedValue({ data: mockData });
|
|
|
|
const { result } = renderHook(() =>
|
|
useFetchData('/test', { onSuccess })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(onSuccess).toHaveBeenCalledWith(mockData);
|
|
});
|
|
|
|
it('should call onError callback on error', async () => {
|
|
const errorMessage = 'Fetch failed';
|
|
const onError = jest.fn();
|
|
(api.get as jest.Mock).mockRejectedValue(new Error(errorMessage));
|
|
|
|
const { result } = renderHook(() =>
|
|
useFetchData('/test', { onError, showErrorToast: false })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(onError).toHaveBeenCalledWith(errorMessage);
|
|
expect(result.current.error).toBe(errorMessage);
|
|
});
|
|
|
|
it('should show error toast by default', async () => {
|
|
(api.get as jest.Mock).mockRejectedValue(new Error('Error'));
|
|
|
|
const { result } = renderHook(() => useFetchData('/test'));
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(toast.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not show error toast when showErrorToast is false', async () => {
|
|
(api.get as jest.Mock).mockRejectedValue(new Error('Error'));
|
|
|
|
const { result } = renderHook(() =>
|
|
useFetchData('/test', { showErrorToast: false })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow refetching data', async () => {
|
|
const mockData1 = { id: 1 };
|
|
const mockData2 = { id: 2 };
|
|
(api.get as jest.Mock)
|
|
.mockResolvedValueOnce({ data: mockData1 })
|
|
.mockResolvedValueOnce({ data: mockData2 });
|
|
|
|
const { result } = renderHook(() => useFetchData('/test'));
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockData1);
|
|
|
|
await act(async () => {
|
|
await result.current.refetch();
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockData2);
|
|
});
|
|
});
|