diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce7e5b..22b1b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Unit tests for `AddMailAccountModal` component (`AddMailAccountModal.test.tsx`): 28 tests covering create/edit mode rendering, provider wizard flow, form field changes, checkbox toggles, auto-detect, test connection, submit mutations, error extraction, and modal close interactions - Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback - Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs - Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions diff --git a/docs/TODO.md b/docs/TODO.md index e73209c..25e8205 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -120,6 +120,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Write tests for Celery tasks (96% coverage for `tasks.py`) - [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.py) - [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites. +- [x] **AddMailAccountModal test coverage**: 28 tests covering create/edit mode rendering, provider wizard flow, form fields, auto-detect, test connection, submit mutations, error extraction, and modal interactions. Total frontend: 147 tests across 9 suites. ### In Progress 🔨 - [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) diff --git a/frontend/src/components/AddMailAccountModal.test.tsx b/frontend/src/components/AddMailAccountModal.test.tsx new file mode 100644 index 0000000..8b6ad1e --- /dev/null +++ b/frontend/src/components/AddMailAccountModal.test.tsx @@ -0,0 +1,546 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AddMailAccountModal } from './AddMailAccountModal'; +import { MailAccount } from '@/lib/api'; + +// ── Mock state ────────────────────────────────────────────────────────────── +const mockMutateAsyncCreate = jest.fn().mockResolvedValue({}); +const mockMutateAsyncUpdate = jest.fn().mockResolvedValue({}); +const mockInvalidateQueries = jest.fn(); + +let createIsPending = false; +let updateIsPending = false; +let mutationCallCount = 0; + +// ── Mocks ─────────────────────────────────────────────────────────────────── + +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn().mockImplementation(() => { + mutationCallCount++; + if (mutationCallCount % 2 === 1) { + return { mutateAsync: mockMutateAsyncCreate, isPending: createIsPending }; + } + return { mutateAsync: mockMutateAsyncUpdate, isPending: updateIsPending }; + }), + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})); + +jest.mock('@/lib/api', () => ({ + mailAccountsApi: { + create: jest.fn(), + update: jest.fn(), + test: jest.fn(), + testExisting: jest.fn(), + autoDetect: jest.fn(), + }, +})); + +jest.mock('@/store/authStore', () => ({ + useAuthStore: () => ({ + user: { email: 'currentuser@example.com' }, + }), +})); + +jest.mock('lucide-react', () => ({ + X: ({ className }: { className?: string }) => ( + + ), + Loader2: ({ className }: { className?: string }) => ( + + ), + CheckCircle: ({ className }: { className?: string }) => ( + + ), + XCircle: ({ className }: { className?: string }) => ( + + ), +})); + +jest.mock('./ProviderWizard', () => ({ + ProviderWizard: ({ + onSelect, + onManual, + }: { + onSelect: (config: unknown) => void; + onManual: () => void; + }) => ( +
+ + +
+ ), +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const mockAccount: MailAccount = { + id: 42, + user_id: 1, + name: 'My Work Email', + email_address: 'work@example.com', + protocol: 'imap_ssl', + host: 'imap.example.com', + port: 993, + use_ssl: true, + use_tls: false, + username: 'work@example.com', + forward_to: 'me@gmail.com', + delivery_method: 'gmail_api', + is_enabled: true, + check_interval_minutes: 10, + max_emails_per_check: 25, + delete_after_forward: false, + status: 'active', + provider_name: 'Gmail', + auto_detected: false, + total_emails_processed: 100, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + last_checked_at: '2024-01-02T12:00:00Z', + last_error: null, +}; + +const { mailAccountsApi } = jest.requireMock('@/lib/api') as { + mailAccountsApi: { + create: jest.Mock; + update: jest.Mock; + test: jest.Mock; + testExisting: jest.Mock; + autoDetect: jest.Mock; + }; +}; + +// ── Setup / Teardown ──────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + mutationCallCount = 0; + createIsPending = false; + updateIsPending = false; +}); + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('AddMailAccountModal', () => { + // 1. Create mode renders ProviderWizard + it('renders ProviderWizard in create mode', () => { + render(); + expect(screen.getByTestId('provider-wizard')).toBeInTheDocument(); + }); + + // 2. Edit mode shows form with pre-filled data + it('renders form with pre-filled data in edit mode', () => { + render(); + expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('My Work Email')).toBeInTheDocument(); + expect(screen.getByDisplayValue('work@example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('imap.example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('993')).toBeInTheDocument(); + }); + + // 3. Shows correct title for create mode + it('shows "Add Mail Account" title in create mode', () => { + render(); + expect(screen.getByText('Add Mail Account')).toBeInTheDocument(); + }); + + // 4. Shows correct title for edit mode + it('shows "Edit Mail Account" title in edit mode', () => { + render(); + expect(screen.getByText('Edit Mail Account')).toBeInTheDocument(); + }); + + // 5. Provider selection fills form and advances to form step + it('fills form and advances to form step on provider selection', () => { + render(); + fireEvent.click(screen.getByText('Select Gmail')); + // Wizard should be gone, form should be visible + expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('Gmail')).toBeInTheDocument(); + expect(screen.getByDisplayValue('imap.gmail.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('993')).toBeInTheDocument(); + }); + + // 6. Back to provider selection works in create mode + it('goes back to provider selection in create mode', () => { + render(); + // Advance to form via manual + fireEvent.click(screen.getByText('Manual Setup')); + expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument(); + // Click back + fireEvent.click(screen.getByText('← Back to provider selection')); + expect(screen.getByTestId('provider-wizard')).toBeInTheDocument(); + }); + + // 7. No back button in edit mode + it('does not show back button in edit mode', () => { + render(); + expect(screen.queryByText('← Back to provider selection')).not.toBeInTheDocument(); + }); + + // 8. Form field changes update state + it('updates form state when fields change', () => { + render(); + const nameInput = screen.getByDisplayValue('My Work Email'); + fireEvent.change(nameInput, { target: { name: 'name', value: 'New Name', type: 'text' } }); + expect(screen.getByDisplayValue('New Name')).toBeInTheDocument(); + }); + + // 9. Username/email sync + it('syncs email_address with username when username changes', () => { + render(); + fireEvent.click(screen.getByText('Manual Setup')); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'new@example.com', type: 'text' }, + }); + // The forward_to field is a different input; email_address is internal state + // We can verify by checking the username input value + expect(usernameInput).toHaveValue('new@example.com'); + }); + + // 10. Checkbox changes work + it('toggles checkbox values', () => { + render(); + const sslCheckbox = screen.getByLabelText('Use SSL/TLS'); + expect(sslCheckbox).toBeChecked(); + fireEvent.click(sslCheckbox); + expect(sslCheckbox).not.toBeChecked(); + + const deleteCheckbox = screen.getByLabelText('Delete after forwarding'); + expect(deleteCheckbox).not.toBeChecked(); + fireEvent.click(deleteCheckbox); + expect(deleteCheckbox).toBeChecked(); + + const enabledCheckbox = screen.getByLabelText('Enabled'); + expect(enabledCheckbox).toBeChecked(); + fireEvent.click(enabledCheckbox); + expect(enabledCheckbox).not.toBeChecked(); + }); + + // 11. Auto-detect success updates form fields + it('updates form on successful auto-detect', async () => { + mailAccountsApi.autoDetect.mockResolvedValue({ + success: true, + suggestions: [{ protocol: 'pop3_ssl', host: 'pop.detected.com', port: 995, use_ssl: true }], + }); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'test@detect.com', type: 'text' }, + }); + fireEvent.click(screen.getByText('Auto-Detect')); + + await waitFor(() => { + expect(screen.getByDisplayValue('pop.detected.com')).toBeInTheDocument(); + }); + expect(window.alert).toHaveBeenCalledWith('Settings auto-detected successfully!'); + }); + + // 12. Auto-detect failure shows alert + it('shows alert on auto-detect failure', async () => { + mailAccountsApi.autoDetect.mockRejectedValue(new Error('fail')); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'test@fail.com', type: 'text' }, + }); + fireEvent.click(screen.getByText('Auto-Detect')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith( + 'Failed to auto-detect settings. Please enter manually.' + ); + }); + }); + + // 13. Auto-detect requires username + it('alerts when auto-detect is attempted without username', () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByText('Manual Setup')); + fireEvent.click(screen.getByText('Auto-Detect')); + expect(window.alert).toHaveBeenCalledWith('Please enter an email address first'); + }); + + // 14. Test connection success + it('shows success message on successful test connection', async () => { + mailAccountsApi.test.mockResolvedValue({ success: true, message: 'Connected!' }); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + // Fill required fields + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'u@test.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'pass123', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'mail.test.com', type: 'text' }, + }); + + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(screen.getByText('Connected!')).toBeInTheDocument(); + }); + expect(screen.getByTestId('icon-CheckCircle')).toBeInTheDocument(); + }); + + // 15. Test connection error + it('shows error message on failed test connection', async () => { + mailAccountsApi.test.mockResolvedValue({ success: false, message: 'Auth failed' }); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'u@test.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'wrong', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'mail.test.com', type: 'text' }, + }); + + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(screen.getByText('Auth failed')).toBeInTheDocument(); + }); + expect(screen.getByTestId('icon-XCircle')).toBeInTheDocument(); + }); + + // 16. Test connection missing fields + it('shows validation error when test fields are missing', async () => { + render(); + fireEvent.click(screen.getByText('Manual Setup')); + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect( + screen.getByText('Please fill in username, password, and host') + ).toBeInTheDocument(); + }); + }); + + // 17. Test connection in edit mode uses testExisting when no password + it('uses testExisting in edit mode when no password is entered', async () => { + mailAccountsApi.testExisting.mockResolvedValue({ + success: true, + message: 'Existing OK', + }); + + render(); + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(mailAccountsApi.testExisting).toHaveBeenCalledWith(42); + }); + expect(screen.getByText('Existing OK')).toBeInTheDocument(); + }); + + // 18. Submit in create mode calls createMutation + it('calls create mutation on submit in create mode', async () => { + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + // Fill required form fields + const nameInput = screen.getByPlaceholderText('My Email Account'); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + const forwardInput = screen.getByPlaceholderText('you@gmail.com'); + + fireEvent.change(nameInput, { + target: { name: 'name', value: 'Test Account', type: 'text' }, + }); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'user@test.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'secret', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'pop.test.com', type: 'text' }, + }); + fireEvent.change(forwardInput, { + target: { name: 'forward_to', value: 'fwd@gmail.com', type: 'email' }, + }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockMutateAsyncCreate).toHaveBeenCalled(); + }); + }); + + // 19. Submit in edit mode calls updateMutation (omits password when blank) + it('calls update mutation on submit in edit mode, omitting blank password', async () => { + render(); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockMutateAsyncUpdate).toHaveBeenCalled(); + }); + const updateArg = mockMutateAsyncUpdate.mock.calls[0][0]; + expect(updateArg.password).toBeUndefined(); + expect(updateArg.name).toBe('My Work Email'); + }); + + // 20. Close button calls onClose + it('calls onClose when close (X) button is clicked', () => { + const onClose = jest.fn(); + render(); + // The X icon button is the one containing the X icon + const closeButton = screen.getByTestId('icon-X').closest('button')!; + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); + + // 21. Backdrop click calls onClose + it('calls onClose when backdrop is clicked', () => { + const onClose = jest.fn(); + const { container } = render( + + ); + // Backdrop is the div with bg-gray-500/75 + const backdrop = container.querySelector('.bg-gray-500\\/75'); + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop!); + expect(onClose).toHaveBeenCalled(); + }); + + // 22. Cancel button calls onClose + it('calls onClose when Cancel button is clicked', () => { + const onClose = jest.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalled(); + }); + + // 23. Save button shows "Saving..." when mutation is pending + it('shows "Saving..." when create mutation is pending', () => { + createIsPending = true; + render(); + fireEvent.click(screen.getByText('Manual Setup')); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + it('shows "Saving..." when update mutation is pending', () => { + updateIsPending = true; + render(); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + // 24. Error message extraction - string detail + it('shows string error detail on submit failure', async () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + mockMutateAsyncUpdate.mockRejectedValueOnce({ + response: { data: { detail: 'Account already exists' } }, + }); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith('Account already exists'); + }); + }); + + // 25. Error message extraction - array detail (validation errors) + it('shows joined validation errors on submit failure', async () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + mockMutateAsyncUpdate.mockRejectedValueOnce({ + response: { + data: { + detail: [{ msg: 'field required' }, { msg: 'invalid email' }], + }, + }, + }); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith('field required; invalid email'); + }); + }); + + // 26. Error message extraction - Error instance + it('shows Error.message on submit failure with Error instance', async () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + mockMutateAsyncCreate.mockRejectedValueOnce(new Error('Network error')); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith('Network error'); + }); + }); + + // Test connection error extraction + it('shows extracted error message on test connection exception', async () => { + mailAccountsApi.test.mockRejectedValue({ + response: { data: { detail: 'Timeout' } }, + }); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'u@t.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'p', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'h.com', type: 'text' }, + }); + + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(screen.getByText('Timeout')).toBeInTheDocument(); + }); + }); +});