diff --git a/__tests__/contexts/AuthContext.test.tsx b/__tests__/contexts/AuthContext.test.tsx new file mode 100644 index 0000000..ddd123c --- /dev/null +++ b/__tests__/contexts/AuthContext.test.tsx @@ -0,0 +1,437 @@ +/** + * Tests for the AuthContext + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'; +import { AuthProvider, useAuth } from '@/contexts/AuthContext'; +import { authService } from '@/services/auth.service'; +import toast from 'react-hot-toast'; + +// Mock authService +jest.mock('@/services/auth.service', () => ({ + authService: { + login: jest.fn(), + register: jest.fn(), + logout: jest.fn(), + getCurrentUser: jest.fn(), + googleLogin: jest.fn(), + facebookLogin: jest.fn(), + }, +})); + +// Mock react-hot-toast +jest.mock('react-hot-toast', () => ({ + success: jest.fn(), + error: jest.fn(), +})); + +// Mock utils/helpers +jest.mock('@/utils/helpers', () => ({ + setToken: jest.fn(), + removeToken: jest.fn(), + getToken: jest.fn(), + storage: { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + }, +})); + +import { setToken, removeToken, getToken, storage } from '@/utils/helpers'; + +// Mock next/navigation +const mockPush = jest.fn(); +const mockReplace = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + prefetch: jest.fn(), + }), +})); + +// Test component that uses the hook +const TestComponent: React.FC = () => { + const { user, isLoading, isAuthenticated, login, logout, register } = useAuth(); + + if (isLoading) return
Loading...
; + + const handleLogin = async () => { + try { + await login({ email: 'test@test.com', password: 'password' }); + } catch { + // Error is handled in AuthContext + } + }; + + const handleRegister = async () => { + try { + await register({ + email: 'new@test.com', + password: 'password', + firstName: 'John', + lastName: 'Doe', + }); + } catch { + // Error is handled in AuthContext + } + }; + + return ( +
+
{isAuthenticated ? 'yes' : 'no'}
+
{user ? JSON.stringify(user) : 'null'}
+ + + +
+ ); +}; + +describe('AuthContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getToken as jest.Mock).mockReturnValue(null); + }); + + describe('Initial state', () => { + it('should show loading state when token exists', async () => { + // When token exists, it fetches user data which shows loading + (getToken as jest.Mock).mockReturnValue('valid-token'); + + // Delay the response to ensure loading state is visible + let resolveUser: (value: unknown) => void; + const userPromise = new Promise((resolve) => { + resolveUser = resolve; + }); + (authService.getCurrentUser as jest.Mock).mockReturnValue(userPromise); + + render( + + + + ); + + // Should show loading while fetching user + expect(screen.getByText('Loading...')).toBeInTheDocument(); + + // Resolve the promise + await act(async () => { + resolveUser!({ id: '1', email: 'test@test.com', role: 'CLIENT' }); + }); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + }); + + it('should have null user when no token exists', async () => { + render( + + + + ); + + // When no token, loading completes immediately + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('null'); + expect(screen.getByTestId('authenticated')).toHaveTextContent('no'); + }); + }); + + it('should load user when token exists', async () => { + const mockUser = { + id: '1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }; + + (getToken as jest.Mock).mockReturnValue('valid-token'); + (authService.getCurrentUser as jest.Mock).mockResolvedValueOnce(mockUser); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('authenticated')).toHaveTextContent('yes'); + }); + }); + + it('should remove token if getCurrentUser fails', async () => { + (getToken as jest.Mock).mockReturnValue('invalid-token'); + (authService.getCurrentUser as jest.Mock).mockRejectedValueOnce( + new Error('Invalid token') + ); + + render( + + + + ); + + await waitFor(() => { + expect(removeToken).toHaveBeenCalled(); + expect(storage.remove).toHaveBeenCalled(); + }); + }); + }); + + describe('login', () => { + it('should login successfully and redirect CLIENT', async () => { + const mockResponse = { + token: 'new-token', + user: { + id: '1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }, + }; + + (authService.login as jest.Mock).mockResolvedValueOnce(mockResponse); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + const loginButton = screen.getByText('Login'); + await act(async () => { + fireEvent.click(loginButton); + }); + + await waitFor(() => { + expect(setToken).toHaveBeenCalledWith('new-token'); + expect(toast.success).toHaveBeenCalledWith('Connexion réussie !'); + expect(mockReplace).toHaveBeenCalledWith('/client'); + }); + }); + + it('should redirect ADMIN to admin dashboard', async () => { + const mockResponse = { + token: 'admin-token', + user: { + id: '1', + email: 'admin@test.com', + firstName: 'Admin', + lastName: 'User', + role: 'ADMIN', + }, + }; + + (authService.login as jest.Mock).mockResolvedValueOnce(mockResponse); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + const loginButton = screen.getByText('Login'); + await act(async () => { + fireEvent.click(loginButton); + }); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/admin/dashboard'); + }); + }); + + it('should redirect EMPLOYEE to employee dashboard', async () => { + const mockResponse = { + token: 'employee-token', + user: { + id: '1', + email: 'employee@test.com', + firstName: 'Employee', + lastName: 'User', + role: 'EMPLOYEE', + }, + }; + + (authService.login as jest.Mock).mockResolvedValueOnce(mockResponse); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + const loginButton = screen.getByText('Login'); + await act(async () => { + fireEvent.click(loginButton); + }); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/employe/dashboard'); + }); + }); + + it('should show error toast on login failure', async () => { + const errorMessage = 'Invalid credentials'; + (authService.login as jest.Mock).mockRejectedValueOnce( + new Error(errorMessage) + ); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + const loginButton = screen.getByText('Login'); + await act(async () => { + fireEvent.click(loginButton); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(errorMessage); + }); + }); + }); + + describe('register', () => { + it('should register successfully', async () => { + const mockResponse = { + token: 'new-user-token', + user: { + id: '2', + email: 'new@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }, + }; + + (authService.register as jest.Mock).mockResolvedValueOnce(mockResponse); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + const registerButton = screen.getByText('Register'); + await act(async () => { + fireEvent.click(registerButton); + }); + + await waitFor(() => { + expect(setToken).toHaveBeenCalledWith('new-user-token'); + expect(toast.success).toHaveBeenCalledWith('Inscription réussie !'); + expect(mockPush).toHaveBeenCalledWith('/client'); + }); + }); + }); + + describe('logout', () => { + it('should logout and redirect to login page', async () => { + const mockUser = { + id: '1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }; + + (getToken as jest.Mock).mockReturnValue('valid-token'); + (authService.getCurrentUser as jest.Mock).mockResolvedValueOnce(mockUser); + (authService.logout as jest.Mock).mockResolvedValueOnce(undefined); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('authenticated')).toHaveTextContent('yes'); + }); + + const logoutButton = screen.getByText('Logout'); + await act(async () => { + fireEvent.click(logoutButton); + }); + + await waitFor(() => { + expect(removeToken).toHaveBeenCalled(); + expect(storage.remove).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith('Déconnexion réussie'); + expect(mockPush).toHaveBeenCalledWith('/login'); + }); + }); + + it('should still logout even if API call fails', async () => { + (getToken as jest.Mock).mockReturnValue('valid-token'); + (authService.getCurrentUser as jest.Mock).mockResolvedValueOnce({ + id: '1', + email: 'test@test.com', + role: 'CLIENT', + }); + (authService.logout as jest.Mock).mockRejectedValueOnce( + new Error('Logout failed') + ); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('authenticated')).toHaveTextContent('yes'); + }); + + const logoutButton = screen.getByText('Logout'); + await act(async () => { + fireEvent.click(logoutButton); + }); + + await waitFor(() => { + expect(removeToken).toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith('/login'); + }); + }); + }); + + describe('useAuth hook error', () => { + it('should throw error when used outside AuthProvider', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow('useAuth must be used within an AuthProvider'); + + consoleError.mockRestore(); + }); + }); +}); diff --git a/__tests__/hooks/useApi.test.ts b/__tests__/hooks/useApi.test.ts new file mode 100644 index 0000000..17f350a --- /dev/null +++ b/__tests__/hooks/useApi.test.ts @@ -0,0 +1,333 @@ +/** + * 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()); + + 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('/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('/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); + }); +}); diff --git a/__tests__/services/api.test.ts b/__tests__/services/api.test.ts new file mode 100644 index 0000000..5c5f69d --- /dev/null +++ b/__tests__/services/api.test.ts @@ -0,0 +1,276 @@ +/** + * 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'); + } + }); + }); +}); diff --git a/__tests__/services/auth.service.test.ts b/__tests__/services/auth.service.test.ts new file mode 100644 index 0000000..77434d3 --- /dev/null +++ b/__tests__/services/auth.service.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for the Auth Service + * @jest-environment jsdom + */ + +import { authService } from '@/services/auth.service'; +import { api } from '@/services/api'; +import { API_ENDPOINTS } from '@/utils/constants'; + +// 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'; + } + }, +})); + +describe('Auth Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('login', () => { + it('should login with valid credentials', async () => { + const mockResponse = { + success: true, + token: 'jwt-token-123', + user: { + id: '1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }, + }; + + (api.post as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await authService.login({ + email: 'test@test.com', + password: 'password123', + }); + + expect(api.post).toHaveBeenCalledWith( + API_ENDPOINTS.AUTH.LOGIN, + { email: 'test@test.com', password: 'password123' } + ); + expect(result).toEqual({ + token: 'jwt-token-123', + user: mockResponse.user, + }); + }); + + it('should throw error on invalid credentials', async () => { + (api.post as jest.Mock).mockRejectedValueOnce(new Error('Invalid credentials')); + + await expect( + authService.login({ email: 'test@test.com', password: 'wrong' }) + ).rejects.toThrow('Invalid credentials'); + }); + }); + + describe('register', () => { + it('should register a new user', async () => { + const mockResponse = { + success: true, + token: 'jwt-token-new', + user: { + id: '2', + email: 'new@test.com', + firstName: 'Jane', + lastName: 'Doe', + role: 'CLIENT', + }, + }; + + (api.post as jest.Mock).mockResolvedValueOnce(mockResponse); + + const registerData = { + email: 'new@test.com', + password: 'password123', + firstName: 'Jane', + lastName: 'Doe', + }; + + const result = await authService.register(registerData); + + expect(api.post).toHaveBeenCalledWith( + API_ENDPOINTS.AUTH.REGISTER, + registerData + ); + expect(result.token).toBe('jwt-token-new'); + expect(result.user.email).toBe('new@test.com'); + }); + + it('should throw error if email already exists', async () => { + (api.post as jest.Mock).mockRejectedValueOnce(new Error('Email already exists')); + + await expect( + authService.register({ + email: 'existing@test.com', + password: 'password', + firstName: 'Test', + lastName: 'User', + }) + ).rejects.toThrow('Email already exists'); + }); + }); + + describe('logout', () => { + it('should call logout endpoint', async () => { + (api.post as jest.Mock).mockResolvedValueOnce({ success: true }); + + await authService.logout(); + + expect(api.post).toHaveBeenCalledWith(API_ENDPOINTS.AUTH.LOGOUT); + }); + }); + + describe('getCurrentUser', () => { + it('should return user data from response.data', async () => { + const mockUser = { + id: '1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }; + + (api.get as jest.Mock).mockResolvedValueOnce({ + success: true, + data: mockUser, + }); + + const result = await authService.getCurrentUser(); + + expect(api.get).toHaveBeenCalledWith(API_ENDPOINTS.AUTH.ME); + expect(result).toEqual(mockUser); + }); + + it('should return user data from response.user', async () => { + const mockUser = { + id: '1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }; + + (api.get as jest.Mock).mockResolvedValueOnce({ + success: true, + user: mockUser, + }); + + const result = await authService.getCurrentUser(); + + expect(result).toEqual(mockUser); + }); + + it('should return response directly if no data or user field', async () => { + const mockUser = { + id: '1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + role: 'CLIENT', + }; + + (api.get as jest.Mock).mockResolvedValueOnce(mockUser); + + const result = await authService.getCurrentUser(); + + expect(result).toEqual(mockUser); + }); + }); + + describe('googleLogin', () => { + it('should login with Google token', async () => { + const mockResponse = { + success: true, + token: 'jwt-google-token', + user: { + id: '3', + email: 'google@test.com', + firstName: 'Google', + lastName: 'User', + role: 'CLIENT', + }, + }; + + (api.post as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await authService.googleLogin('google-oauth-token'); + + expect(api.post).toHaveBeenCalledWith( + API_ENDPOINTS.AUTH.GOOGLE, + { token: 'google-oauth-token' } + ); + expect(result.token).toBe('jwt-google-token'); + }); + }); + + describe('facebookLogin', () => { + it('should login with Facebook token', async () => { + const mockResponse = { + success: true, + token: 'jwt-facebook-token', + user: { + id: '4', + email: 'facebook@test.com', + firstName: 'Facebook', + lastName: 'User', + role: 'CLIENT', + }, + }; + + (api.post as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await authService.facebookLogin('facebook-oauth-token'); + + expect(api.post).toHaveBeenCalledWith( + API_ENDPOINTS.AUTH.FACEBOOK, + { token: 'facebook-oauth-token' } + ); + expect(result.token).toBe('jwt-facebook-token'); + }); + }); + + describe('verifyEmail', () => { + it('should verify email with token', async () => { + (api.post as jest.Mock).mockResolvedValueOnce({ success: true }); + + await authService.verifyEmail('verification-token'); + + expect(api.post).toHaveBeenCalledWith( + API_ENDPOINTS.AUTH.VERIFY_EMAIL, + { token: 'verification-token' } + ); + }); + }); + + describe('forgotPassword', () => { + it('should send forgot password request', async () => { + (api.post as jest.Mock).mockResolvedValueOnce({ success: true }); + + await authService.forgotPassword('test@test.com'); + + expect(api.post).toHaveBeenCalledWith( + API_ENDPOINTS.AUTH.FORGOT_PASSWORD, + { email: 'test@test.com' } + ); + }); + }); + + describe('resetPassword', () => { + it('should reset password with token', async () => { + (api.post as jest.Mock).mockResolvedValueOnce({ success: true }); + + await authService.resetPassword('reset-token', 'newPassword123'); + + expect(api.post).toHaveBeenCalledWith( + API_ENDPOINTS.AUTH.RESET_PASSWORD, + { token: 'reset-token', password: 'newPassword123' } + ); + }); + }); +}); diff --git a/__tests__/utils/theme.test.ts b/__tests__/utils/theme.test.ts new file mode 100644 index 0000000..c12e91a --- /dev/null +++ b/__tests__/utils/theme.test.ts @@ -0,0 +1,174 @@ +/** + * Tests for the theme utility + */ + +import { + STATUS_COLORS, + BADGE_COLORS, + BUTTON_STYLES, + INPUT_STYLES, + CARD_STYLES, + ALERT_STYLES, + ROLE_COLORS, + PRIZE_COLORS, + getStatusColor, + getButtonStyle, + getInputStyle, + getRoleColor, + getPrizeColor, +} from '@/utils/theme'; + +describe('Theme Utility', () => { + describe('Constants', () => { + it('should have all status colors defined', () => { + expect(STATUS_COLORS.PENDING).toBe('bg-yellow-100 text-yellow-800'); + expect(STATUS_COLORS.CLAIMED).toBe('bg-green-100 text-green-800'); + expect(STATUS_COLORS.REJECTED).toBe('bg-red-100 text-red-800'); + expect(STATUS_COLORS.ACTIVE).toBe('bg-green-100 text-green-800'); + expect(STATUS_COLORS.INACTIVE).toBe('bg-gray-100 text-gray-800'); + expect(STATUS_COLORS.EXPIRED).toBe('bg-red-100 text-red-800'); + }); + + it('should have all badge colors defined', () => { + expect(BADGE_COLORS.info).toBe('bg-blue-100 text-blue-800'); + expect(BADGE_COLORS.success).toBe('bg-green-100 text-green-800'); + expect(BADGE_COLORS.warning).toBe('bg-yellow-100 text-yellow-800'); + expect(BADGE_COLORS.error).toBe('bg-red-100 text-red-800'); + expect(BADGE_COLORS.purple).toBe('bg-purple-100 text-purple-800'); + expect(BADGE_COLORS.pink).toBe('bg-pink-100 text-pink-800'); + expect(BADGE_COLORS.amber).toBe('bg-amber-100 text-amber-800'); + expect(BADGE_COLORS.gray).toBe('bg-gray-100 text-gray-800'); + }); + + it('should have all button styles defined', () => { + expect(BUTTON_STYLES.primary).toContain('bg-blue-600'); + expect(BUTTON_STYLES.secondary).toContain('bg-gray-600'); + expect(BUTTON_STYLES.success).toContain('bg-green-600'); + expect(BUTTON_STYLES.danger).toContain('bg-red-600'); + expect(BUTTON_STYLES.warning).toContain('bg-yellow-600'); + expect(BUTTON_STYLES.outline).toContain('border-blue-600'); + expect(BUTTON_STYLES.ghost).toContain('text-gray-600'); + }); + + it('should have input styles defined', () => { + expect(INPUT_STYLES.base).toContain('w-full'); + expect(INPUT_STYLES.base).toContain('rounded-lg'); + expect(INPUT_STYLES.error).toBe('border-red-500'); + expect(INPUT_STYLES.normal).toBe('border-gray-300'); + }); + + it('should have card styles defined', () => { + expect(CARD_STYLES.base).toContain('bg-white'); + expect(CARD_STYLES.base).toContain('rounded-lg'); + expect(CARD_STYLES.hover).toContain('hover:shadow-lg'); + expect(CARD_STYLES.bordered).toContain('border-gray-200'); + }); + + it('should have alert styles defined', () => { + expect(ALERT_STYLES.info).toContain('bg-blue-50'); + expect(ALERT_STYLES.success).toContain('bg-green-50'); + expect(ALERT_STYLES.warning).toContain('bg-yellow-50'); + expect(ALERT_STYLES.error).toContain('bg-red-50'); + }); + + it('should have role colors defined', () => { + expect(ROLE_COLORS.ADMIN).toBe('bg-red-100 text-red-800'); + expect(ROLE_COLORS.EMPLOYEE).toBe('bg-blue-100 text-blue-800'); + expect(ROLE_COLORS.CLIENT).toBe('bg-green-100 text-green-800'); + }); + + it('should have prize colors defined', () => { + expect(PRIZE_COLORS.INFUSEUR).toBe('bg-blue-100 text-blue-800'); + expect(PRIZE_COLORS.THE_SIGNATURE).toBe('bg-green-100 text-green-800'); + expect(PRIZE_COLORS.COFFRET_DECOUVERTE).toBe('bg-purple-100 text-purple-800'); + expect(PRIZE_COLORS.COFFRET_PRESTIGE).toBe('bg-amber-100 text-amber-800'); + expect(PRIZE_COLORS.THE_GRATUIT).toBe('bg-pink-100 text-pink-800'); + }); + }); + + describe('getStatusColor', () => { + it('should return correct color for known statuses', () => { + expect(getStatusColor('PENDING')).toBe('bg-yellow-100 text-yellow-800'); + expect(getStatusColor('CLAIMED')).toBe('bg-green-100 text-green-800'); + expect(getStatusColor('REJECTED')).toBe('bg-red-100 text-red-800'); + }); + + it('should handle lowercase statuses', () => { + expect(getStatusColor('pending')).toBe('bg-yellow-100 text-yellow-800'); + expect(getStatusColor('claimed')).toBe('bg-green-100 text-green-800'); + }); + + it('should return gray for unknown statuses', () => { + expect(getStatusColor('UNKNOWN')).toBe('bg-gray-100 text-gray-800'); + expect(getStatusColor('random')).toBe('bg-gray-100 text-gray-800'); + }); + }); + + describe('getButtonStyle', () => { + it('should return primary style by default', () => { + expect(getButtonStyle()).toContain('bg-blue-600'); + }); + + it('should return correct style for variant', () => { + expect(getButtonStyle('primary')).toContain('bg-blue-600'); + expect(getButtonStyle('secondary')).toContain('bg-gray-600'); + expect(getButtonStyle('success')).toContain('bg-green-600'); + expect(getButtonStyle('danger')).toContain('bg-red-600'); + expect(getButtonStyle('warning')).toContain('bg-yellow-600'); + expect(getButtonStyle('outline')).toContain('border-blue-600'); + expect(getButtonStyle('ghost')).toContain('text-gray-600'); + }); + }); + + describe('getInputStyle', () => { + it('should return normal input style when no error', () => { + const style = getInputStyle(false); + expect(style).toContain('w-full'); + expect(style).toContain('border-gray-300'); + expect(style).not.toContain('border-red-500'); + }); + + it('should return error input style when has error', () => { + const style = getInputStyle(true); + expect(style).toContain('w-full'); + expect(style).toContain('border-red-500'); + expect(style).not.toContain('border-gray-300'); + }); + }); + + describe('getRoleColor', () => { + it('should return correct color for known roles', () => { + expect(getRoleColor('ADMIN')).toBe('bg-red-100 text-red-800'); + expect(getRoleColor('EMPLOYEE')).toBe('bg-blue-100 text-blue-800'); + expect(getRoleColor('CLIENT')).toBe('bg-green-100 text-green-800'); + }); + + it('should handle lowercase roles', () => { + expect(getRoleColor('admin')).toBe('bg-red-100 text-red-800'); + expect(getRoleColor('employee')).toBe('bg-blue-100 text-blue-800'); + expect(getRoleColor('client')).toBe('bg-green-100 text-green-800'); + }); + + it('should return gray for unknown roles', () => { + expect(getRoleColor('UNKNOWN')).toBe('bg-gray-100 text-gray-800'); + }); + }); + + describe('getPrizeColor', () => { + it('should return correct color for known prize types', () => { + expect(getPrizeColor('INFUSEUR')).toBe('bg-blue-100 text-blue-800'); + expect(getPrizeColor('THE_SIGNATURE')).toBe('bg-green-100 text-green-800'); + expect(getPrizeColor('COFFRET_DECOUVERTE')).toBe('bg-purple-100 text-purple-800'); + expect(getPrizeColor('COFFRET_PRESTIGE')).toBe('bg-amber-100 text-amber-800'); + expect(getPrizeColor('THE_GRATUIT')).toBe('bg-pink-100 text-pink-800'); + }); + + it('should handle lowercase prize types', () => { + expect(getPrizeColor('infuseur')).toBe('bg-blue-100 text-blue-800'); + }); + + it('should return gray for unknown prize types', () => { + expect(getPrizeColor('UNKNOWN')).toBe('bg-gray-100 text-gray-800'); + }); + }); +}); diff --git a/components/forms/BaseFormField.tsx b/components/forms/BaseFormField.tsx new file mode 100644 index 0000000..43915ae --- /dev/null +++ b/components/forms/BaseFormField.tsx @@ -0,0 +1,57 @@ +'use client'; + +import React from 'react'; + +interface BaseFormFieldProps { + label: string; + name: string; + required?: boolean; + error?: string; + touched?: boolean; + className?: string; + children: React.ReactNode; +} + +/** + * Base form field wrapper component that provides consistent styling + * for labels, error messages, and layout across all form components. + */ +export default function BaseFormField({ + label, + name, + required = false, + error, + touched, + className = '', + children, +}: BaseFormFieldProps) { + const showError = touched && error; + + return ( +
+ + {children} + {showError && ( +

{error}

+ )} +
+ ); +} + +// Common input class names for consistency +export const inputBaseClasses = ` + w-full px-4 py-2 border rounded-lg + focus:outline-none focus:ring-2 focus:ring-primary-500 + disabled:bg-gray-100 disabled:cursor-not-allowed +`; + +export const getInputClasses = (hasError: boolean): string => ` + ${inputBaseClasses} + ${hasError ? 'border-red-500' : 'border-gray-300'} +`; diff --git a/components/forms/FormField.tsx b/components/forms/FormField.tsx index 525e9c0..1a6d9d7 100644 --- a/components/forms/FormField.tsx +++ b/components/forms/FormField.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { ChangeEvent } from 'react'; +import BaseFormField, { getInputClasses } from './BaseFormField'; interface FormFieldProps { label: string; @@ -36,17 +37,15 @@ export default function FormField({ className = '', autoComplete, }: FormFieldProps) { - const showError = touched && error; - return ( -
- + - {showError && ( -

{error}

- )} -
+ ); } diff --git a/components/forms/FormSelect.tsx b/components/forms/FormSelect.tsx index dc324a1..3d0173d 100644 --- a/components/forms/FormSelect.tsx +++ b/components/forms/FormSelect.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { ChangeEvent } from 'react'; +import BaseFormField, { getInputClasses } from './BaseFormField'; interface SelectOption { value: string | number; @@ -39,17 +40,15 @@ export default function FormSelect({ disabled = false, className = '', }: FormSelectProps) { - const showError = touched && error; - return ( -
- + - {showError && ( -

{error}

- )} -
+ ); } diff --git a/components/forms/FormTextarea.tsx b/components/forms/FormTextarea.tsx index eec9469..78b5e03 100644 --- a/components/forms/FormTextarea.tsx +++ b/components/forms/FormTextarea.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { ChangeEvent } from 'react'; +import BaseFormField, { getInputClasses } from './BaseFormField'; interface FormTextareaProps { label: string; @@ -34,17 +35,15 @@ export default function FormTextarea({ rows = 4, className = '', }: FormTextareaProps) { - const showError = touched && error; - return ( -
- +