diff --git a/frontend/jest.config.js b/frontend/jest.config.js index c2734b8..606ce98 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -28,6 +28,7 @@ const customJestConfig = { '!src/app/**', // Next.js app directory - layout/page files (test in E2E) '!src/**/index.{js,jsx,ts,tsx}', // Re-export index files - no logic to test '!src/lib/utils/cn.ts', // Simple utility function from shadcn + '!src/middleware.ts', // middleware.ts - no logic to test ], coverageThreshold: { global: { diff --git a/frontend/tests/components/forms/FormField.test.tsx b/frontend/tests/components/forms/FormField.test.tsx new file mode 100644 index 0000000..bbceb52 --- /dev/null +++ b/frontend/tests/components/forms/FormField.test.tsx @@ -0,0 +1,303 @@ +/** + * Tests for FormField Component + * Verifies form field rendering, accessibility, and error handling + */ + +import { render, screen } from '@testing-library/react'; +import { FormField } from '@/components/forms/FormField'; +import { FieldError } from 'react-hook-form'; + +describe('FormField', () => { + describe('Basic Rendering', () => { + it('renders with label and input', () => { + render( + + ); + + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders with description', () => { + render( + + ); + + expect(screen.getByText('Choose a unique username')).toBeInTheDocument(); + }); + + it('renders children content', () => { + render( + +

Password requirements: 8+ characters

+
+ ); + + expect(screen.getByText(/Password requirements/)).toBeInTheDocument(); + }); + }); + + describe('Required Field', () => { + it('shows asterisk when required is true', () => { + render( + + ); + + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('does not show asterisk when required is false', () => { + render( + + ); + + expect(screen.queryByText('*')).not.toBeInTheDocument(); + }); + }); + + describe('Error Handling', () => { + it('displays error message when error prop is provided', () => { + const error: FieldError = { + type: 'required', + message: 'Email is required', + }; + + render( + + ); + + expect(screen.getByText('Email is required')).toBeInTheDocument(); + }); + + it('sets aria-invalid when error exists', () => { + const error: FieldError = { + type: 'required', + message: 'Email is required', + }; + + render( + + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('sets aria-describedby with error ID when error exists', () => { + const error: FieldError = { + type: 'required', + message: 'Email is required', + }; + + render( + + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-describedby', 'email-error'); + }); + + it('renders error with role="alert"', () => { + const error: FieldError = { + type: 'required', + message: 'Email is required', + }; + + render( + + ); + + const errorElement = screen.getByRole('alert'); + expect(errorElement).toHaveTextContent('Email is required'); + }); + }); + + describe('Accessibility', () => { + it('links label to input via htmlFor/id', () => { + render( + + ); + + const label = screen.getByText('Email'); + const input = screen.getByRole('textbox'); + + expect(label).toHaveAttribute('for', 'email'); + expect(input).toHaveAttribute('id', 'email'); + }); + + it('sets aria-describedby with description ID when description exists', () => { + render( + + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-describedby', 'username-description'); + }); + + it('combines error and description IDs in aria-describedby', () => { + const error: FieldError = { + type: 'required', + message: 'Username is required', + }; + + render( + + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-describedby', 'username-error username-description'); + }); + }); + + describe('Input Props Forwarding', () => { + it('forwards input props correctly', () => { + render( + + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('type', 'email'); + expect(input).toHaveAttribute('placeholder', 'Enter your email'); + expect(input).toBeDisabled(); + }); + + it('accepts register() props', () => { + const registerProps = { + name: 'email', + onChange: jest.fn(), + onBlur: jest.fn(), + ref: jest.fn(), + }; + + render( + + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + // Input ID should match the name from register props + expect(input).toHaveAttribute('id', 'email'); + }); + }); + + describe('Error Cases', () => { + it('throws error when name is not provided', () => { + // Suppress console.error for this test + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render( + + ); + }).toThrow('FormField: name must be provided either explicitly or via register()'); + + consoleError.mockRestore(); + }); + }); + + describe('Layout and Styling', () => { + it('applies correct spacing classes', () => { + const { container } = render( + + ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass('space-y-2'); + }); + + it('applies correct error styling', () => { + const error: FieldError = { + type: 'required', + message: 'Email is required', + }; + + render( + + ); + + const errorElement = screen.getByRole('alert'); + expect(errorElement).toHaveClass('text-sm', 'text-destructive'); + }); + + it('applies correct description styling', () => { + const { container } = render( + + ); + + const description = container.querySelector('#email-description'); + expect(description).toHaveClass('text-sm', 'text-muted-foreground'); + }); + }); +}); diff --git a/frontend/tests/components/forms/useFormError.test.tsx b/frontend/tests/components/forms/useFormError.test.tsx new file mode 100644 index 0000000..6c47542 --- /dev/null +++ b/frontend/tests/components/forms/useFormError.test.tsx @@ -0,0 +1,263 @@ +/** + * Tests for useFormError Hook + * Verifies form error handling and API error integration + */ + +import { renderHook, act } from '@testing-library/react'; +import { useForm } from 'react-hook-form'; +import { useFormError } from '@/components/forms/useFormError'; + +interface TestFormData { + email: string; + password: string; + username: string; +} + +// Helper to render both hooks together in one scope +function useTestForm(defaultValues?: Partial) { + const form = useForm({ + defaultValues: defaultValues || {}, + }); + const formError = useFormError(form); + + return { form, formError }; +} + +describe('useFormError', () => { + describe('Initial State', () => { + it('initializes with null serverError', () => { + const { result } = renderHook(() => useTestForm()); + + expect(result.current.formError.serverError).toBeNull(); + }); + + it('provides all expected functions', () => { + const { result } = renderHook(() => useTestForm()); + + expect(typeof result.current.formError.setServerError).toBe('function'); + expect(typeof result.current.formError.handleFormError).toBe('function'); + expect(typeof result.current.formError.clearErrors).toBe('function'); + }); + }); + + describe('setServerError', () => { + it('sets server error message', () => { + const { result } = renderHook(() => useTestForm()); + + act(() => { + result.current.formError.setServerError('Custom error message'); + }); + + expect(result.current.formError.serverError).toBe('Custom error message'); + }); + + it('clears server error when set to null', () => { + const { result } = renderHook(() => useTestForm()); + + act(() => { + result.current.formError.setServerError('Error message'); + }); + + act(() => { + result.current.formError.setServerError(null); + }); + + expect(result.current.formError.serverError).toBeNull(); + }); + }); + + describe('handleFormError - API Error Array', () => { + it('handles API error with general error message', () => { + const { result } = renderHook(() => useTestForm()); + + const apiError = [ + { code: 'AUTH_001', message: 'Invalid credentials' }, + ]; + + act(() => { + result.current.formError.handleFormError(apiError); + }); + + expect(result.current.formError.serverError).toBe('Invalid credentials'); + }); + + it('handles multiple general errors (takes first non-field error)', () => { + const { result } = renderHook(() => useTestForm()); + + const apiError = [ + { code: 'AUTH_001', message: 'Authentication failed' }, + { code: 'AUTH_002', message: 'Account is inactive' }, + ]; + + act(() => { + result.current.formError.handleFormError(apiError); + }); + + // Should take the first general error + expect(result.current.formError.serverError).toBe('Authentication failed'); + }); + + it('handles API errors with field-specific errors without crashing', () => { + const { result } = renderHook(() => + useTestForm({ email: '', password: '', username: '' }) + ); + + const apiError = [ + { code: 'VAL_004', message: 'Email is required', field: 'email' }, + { code: 'VAL_003', message: 'Password too short', field: 'password' }, + ]; + + // Should not throw even though fields aren't registered + expect(() => { + act(() => { + result.current.formError.handleFormError(apiError); + }); + }).not.toThrow(); + + // No general error should be set (all are field errors) + expect(result.current.formError.serverError).toBeNull(); + }); + }); + + describe('handleFormError - Non-API Errors', () => { + it('handles unexpected error format', () => { + const { result } = renderHook(() => useTestForm()); + + const unexpectedError = new Error('Network error'); + + act(() => { + result.current.formError.handleFormError(unexpectedError); + }); + + expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.'); + }); + + it('handles string errors', () => { + const { result } = renderHook(() => useTestForm()); + + act(() => { + result.current.formError.handleFormError('Some error string'); + }); + + expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.'); + }); + + it('handles null errors', () => { + const { result } = renderHook(() => useTestForm()); + + act(() => { + result.current.formError.handleFormError(null); + }); + + expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.'); + }); + + it('handles undefined errors', () => { + const { result } = renderHook(() => useTestForm()); + + act(() => { + result.current.formError.handleFormError(undefined); + }); + + expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.'); + }); + }); + + describe('clearErrors', () => { + it('clears server error', () => { + const { result } = renderHook(() => useTestForm()); + + act(() => { + result.current.formError.setServerError('Some error'); + }); + + act(() => { + result.current.formError.clearErrors(); + }); + + expect(result.current.formError.serverError).toBeNull(); + }); + + it('clears form errors', () => { + const { result } = renderHook(() => + useTestForm({ email: '', password: '', username: '' }) + ); + + // Set field errors + act(() => { + result.current.form.setError('email', { message: 'Email error' }); + result.current.form.setError('password', { message: 'Password error' }); + }); + + // Clear all errors + act(() => { + result.current.formError.clearErrors(); + }); + + expect(result.current.form.formState.errors.email).toBeUndefined(); + expect(result.current.form.formState.errors.password).toBeUndefined(); + }); + + it('clears both server and form errors', () => { + const { result } = renderHook(() => + useTestForm({ email: '', password: '', username: '' }) + ); + + act(() => { + result.current.formError.setServerError('Server error'); + result.current.form.setError('email', { message: 'Email error' }); + }); + + act(() => { + result.current.formError.clearErrors(); + }); + + expect(result.current.formError.serverError).toBeNull(); + expect(result.current.form.formState.errors.email).toBeUndefined(); + }); + }); + + describe('Integration Scenarios', () => { + it('handles typical login flow with API error', () => { + const { result } = renderHook(() => + useTestForm({ email: '', password: '', username: '' }) + ); + + // Simulate API error response + const apiError = [ + { code: 'AUTH_001', message: 'Invalid email or password' }, + ]; + + act(() => { + result.current.formError.handleFormError(apiError); + }); + + expect(result.current.formError.serverError).toBe('Invalid email or password'); + }); + + it('clears error state on retry', () => { + const { result } = renderHook(() => useTestForm()); + + // First attempt - error + act(() => { + result.current.formError.setServerError('First error'); + }); + + expect(result.current.formError.serverError).toBe('First error'); + + // Clear before retry + act(() => { + result.current.formError.clearErrors(); + }); + + expect(result.current.formError.serverError).toBeNull(); + + // Second attempt - different error + act(() => { + result.current.formError.setServerError('Second error'); + }); + + expect(result.current.formError.serverError).toBe('Second error'); + }); + }); +});