diff --git a/CHANGELOG.md b/CHANGELOG.md index 385281e..926c5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Domain-based logo fallback for mail accounts**: `ProviderLogoBanner` now shows provider logos even for accounts that have no `provider_name` set, by extracting the domain from the email address and matching it against a new `DOMAIN_ICON_MAP`. Covers Gmail, GMX, WEB.DE, Yahoo Mail, AOL, T-Online, Outlook/Hotmail, IONOS, Freenet, iCloud, Posteo, and Proton Mail. +- **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components, utility functions, and API interceptors. Installed `@testing-library/react`, `@testing-library/jest-dom`, and `@testing-library/user-event`. New suites: `date-utils.test.ts` (30 tests), `api.test.ts` (9 tests), `AuthGuard.test.tsx` (6 tests), `QueryProvider.test.tsx` (2 tests), `DashboardLayout.test.tsx` (14 tests), `NotificationWizard.test.tsx` (32 tests), `ProviderWizard.test.tsx` (20 tests). Total frontend: 119 tests across 8 suites. ### Fixed diff --git a/docs/TODO.md b/docs/TODO.md index 10a8031..3c4cf4b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -115,6 +115,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Write unit tests for schemas and validation - [x] Write unit tests for application factory and core endpoints - [x] Reach 50%+ test coverage (currently 59%) +- [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. ### In Progress 🔨 - [ ] Write unit tests for authentication (target 80%+ coverage) diff --git a/frontend/src/components/DashboardLayout.test.tsx b/frontend/src/components/DashboardLayout.test.tsx index 5818a33..0946925 100644 --- a/frontend/src/components/DashboardLayout.test.tsx +++ b/frontend/src/components/DashboardLayout.test.tsx @@ -12,9 +12,11 @@ jest.mock('next/navigation', () => ({ // Mock next/link to render a simple anchor jest.mock('next/link', () => { - return ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( {children} ); + MockLink.displayName = 'MockLink'; + return MockLink; }); // Mock @tanstack/react-query diff --git a/frontend/src/components/NotificationWizard.test.tsx b/frontend/src/components/NotificationWizard.test.tsx new file mode 100644 index 0000000..7c00d78 --- /dev/null +++ b/frontend/src/components/NotificationWizard.test.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NotificationWizard } from './NotificationWizard'; + +// Mock lucide-react icons as simple spans +jest.mock('lucide-react', () => ({ + ArrowLeft: ({ className }: { className?: string }) => , + Bell: ({ className }: { className?: string }) => , + Check: ({ className }: { className?: string }) => , + Eye: ({ className }: { className?: string }) => , + EyeOff: ({ className }: { className?: string }) => , + Send: ({ className }: { className?: string }) => , +})); + +const mockOnComplete = jest.fn(); +const mockOnCancel = jest.fn(); + +describe('NotificationWizard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── Step 1: Channel Selection ────────────────────────────────────────── + + describe('Step 1 - Channel Selection', () => { + it('should render all channel options', () => { + render(); + expect(screen.getByText('Telegram')).toBeInTheDocument(); + expect(screen.getByText('Discord')).toBeInTheDocument(); + expect(screen.getByText('Slack')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('Webhook')).toBeInTheDocument(); + expect(screen.getByText('Custom Apprise URL')).toBeInTheDocument(); + }); + + it('should show the channel selection heading', () => { + render(); + expect(screen.getByText('Choose Notification Channel')).toBeInTheDocument(); + }); + + it('should call onCancel when Cancel button is clicked', () => { + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('should navigate to step 2 when a channel is selected', () => { + render(); + fireEvent.click(screen.getByText('Telegram')); + // Step 2 shows field labels + expect(screen.getByText('Bot Token')).toBeInTheDocument(); + expect(screen.getByText('Chat ID')).toBeInTheDocument(); + }); + }); + + // ── Step 2: Field Entry ──────────────────────────────────────────────── + + describe('Step 2 - Field Entry', () => { + function goToStep2(channel: string) { + render(); + fireEvent.click(screen.getByText(channel)); + } + + it('should show Telegram fields', () => { + goToStep2('Telegram'); + expect(screen.getByText('Bot Token')).toBeInTheDocument(); + expect(screen.getByText('Chat ID')).toBeInTheDocument(); + }); + + it('should show Discord fields', () => { + goToStep2('Discord'); + expect(screen.getByText('Discord Webhook URL')).toBeInTheDocument(); + }); + + it('should show Slack fields', () => { + goToStep2('Slack'); + expect(screen.getByText('Slack Webhook URL')).toBeInTheDocument(); + }); + + it('should show Email fields', () => { + goToStep2('Email'); + expect(screen.getByText('Username / Email')).toBeInTheDocument(); + expect(screen.getByText('SMTP Password')).toBeInTheDocument(); + expect(screen.getByText('SMTP Host')).toBeInTheDocument(); + expect(screen.getByText('SMTP Port')).toBeInTheDocument(); + }); + + it('should show Webhook fields', () => { + goToStep2('Webhook'); + expect(screen.getByText('Webhook URL')).toBeInTheDocument(); + }); + + it('should show Custom Apprise URL fields', () => { + goToStep2('Custom Apprise URL'); + expect(screen.getByText('Apprise URL')).toBeInTheDocument(); + }); + + it('should go back to step 1 when Back button is clicked', () => { + goToStep2('Telegram'); + fireEvent.click(screen.getByText('Back to channel selection')); + expect(screen.getByText('Choose Notification Channel')).toBeInTheDocument(); + }); + + it('should disable Next button when required fields are empty', () => { + goToStep2('Telegram'); + const nextButton = screen.getByText('Next').closest('button'); + expect(nextButton).toBeDisabled(); + }); + + it('should enable Next button when required fields are filled', () => { + goToStep2('Telegram'); + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw' } }); + fireEvent.change(inputs[1], { target: { value: '12345678' } }); + const nextButton = screen.getByText('Next').closest('button'); + expect(nextButton).not.toBeDisabled(); + }); + + it('should call onCancel when Cancel is clicked on step 2', () => { + goToStep2('Telegram'); + fireEvent.click(screen.getByText('Cancel')); + expect(mockOnCancel).toHaveBeenCalled(); + }); + }); + + // ── Step 3: Preview + Preferences ────────────────────────────────────── + + describe('Step 3 - Preview and Preferences', () => { + function goToStep3WithTelegram() { + render(); + // Step 1: select Telegram + fireEvent.click(screen.getByText('Telegram')); + // Step 2: fill fields + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: 'mybot:AAHdqTcvCH1vGWJx' } }); + fireEvent.change(inputs[1], { target: { value: '12345678' } }); + // Go to step 3 + fireEvent.click(screen.getByText('Next').closest('button')!); + } + + it('should show Final Setup heading', () => { + goToStep3WithTelegram(); + expect(screen.getByText('Final Setup')).toBeInTheDocument(); + }); + + it('should show Channel Name input', () => { + goToStep3WithTelegram(); + expect(screen.getByText('Channel Name')).toBeInTheDocument(); + }); + + it('should show notification preference checkboxes', () => { + goToStep3WithTelegram(); + expect(screen.getByText('Email processing errors occur')).toBeInTheDocument(); + expect(screen.getByText('Emails are successfully forwarded')).toBeInTheDocument(); + }); + + it('should have errors checkbox checked by default', () => { + goToStep3WithTelegram(); + const checkboxes = screen.getAllByRole('checkbox'); + // First checkbox is "notify on errors" (default: true) + expect(checkboxes[0]).toBeChecked(); + // Second is "notify on success" (default: false) + expect(checkboxes[1]).not.toBeChecked(); + }); + + it('should disable Save button when name is empty', () => { + goToStep3WithTelegram(); + const saveButton = screen.getByText('Save Channel').closest('button'); + expect(saveButton).toBeDisabled(); + }); + + it('should enable Save button when name is filled', () => { + goToStep3WithTelegram(); + const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert'); + fireEvent.change(nameInput, { target: { value: 'My Alert' } }); + const saveButton = screen.getByText('Save Channel').closest('button'); + expect(saveButton).not.toBeDisabled(); + }); + + it('should call onComplete with correct data on Save', () => { + goToStep3WithTelegram(); + const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert'); + fireEvent.change(nameInput, { target: { value: 'My Telegram Alert' } }); + fireEvent.click(screen.getByText('Save Channel').closest('button')!); + + expect(mockOnComplete).toHaveBeenCalledWith({ + name: 'My Telegram Alert', + channel: 'telegram', + apprise_url: 'tgram://mybot:AAHdqTcvCH1vGWJx/12345678/', + notify_on_errors: true, + notify_on_success: false, + }); + }); + + it('should toggle notification preferences', () => { + goToStep3WithTelegram(); + const checkboxes = screen.getAllByRole('checkbox'); + + // Uncheck errors + fireEvent.click(checkboxes[0]); + // Check success + fireEvent.click(checkboxes[1]); + + const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert'); + fireEvent.change(nameInput, { target: { value: 'Test' } }); + fireEvent.click(screen.getByText('Save Channel').closest('button')!); + + expect(mockOnComplete).toHaveBeenCalledWith( + expect.objectContaining({ + notify_on_errors: false, + notify_on_success: true, + }) + ); + }); + + it('should toggle URL visibility', () => { + goToStep3WithTelegram(); + // URL should be hidden by default (masked with dots) + const urlText = screen.getByText(/^•+$/); + expect(urlText).toBeInTheDocument(); + + // Click the show/hide button + const toggleButton = screen.getByLabelText('Show URL'); + fireEvent.click(toggleButton); + + // Now the URL should be visible + expect(screen.getByText(/^tgram:\/\//)).toBeInTheDocument(); + }); + + it('should go back to step 2 when Back is clicked', () => { + goToStep3WithTelegram(); + fireEvent.click(screen.getByText('Back to configuration')); + // Should be back on step 2 with Telegram fields + expect(screen.getByText('Bot Token')).toBeInTheDocument(); + }); + }); + + // ── buildAppriseUrl via component behavior ───────────────────────────── + + describe('Apprise URL generation', () => { + function fillAndSubmit(channel: string, fields: Record) { + render(); + fireEvent.click(screen.getByText(channel)); + + const inputs = screen.getAllByRole('textbox'); + const passwordInputs = document.querySelectorAll('input[type="password"]'); + const allInputs = [...Array.from(inputs), ...Array.from(passwordInputs)]; + + Object.values(fields).forEach((value, i) => { + fireEvent.change(allInputs[i], { target: { value } }); + }); + + fireEvent.click(screen.getByText('Next').closest('button')!); + + const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert'); + fireEvent.change(nameInput, { target: { value: 'Test' } }); + fireEvent.click(screen.getByText('Save Channel').closest('button')!); + + return mockOnComplete.mock.calls[0][0].apprise_url; + } + + it('should build correct Telegram URL', () => { + const url = fillAndSubmit('Telegram', { + bot_token: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw', + chat_id: '12345678', + }); + expect(url).toBe('tgram://110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/12345678/'); + }); + + it('should build correct Discord URL from webhook', () => { + const url = fillAndSubmit('Discord', { + webhook_url: 'https://discord.com/api/webhooks/123456789/abcdefghij', + }); + expect(url).toBe('discord://123456789/abcdefghij/'); + }); + + it('should build correct Slack URL from webhook', () => { + const url = fillAndSubmit('Slack', { + webhook_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX', + }); + expect(url).toBe('slack://T00000000/B00000000/XXXXXXXXXXXXXXXX/'); + }); + + it('should build correct Webhook URL', () => { + const url = fillAndSubmit('Webhook', { + url: 'https://hooks.example.com/notify', + }); + expect(url).toBe('https://hooks.example.com/notify'); + }); + + it('should pass through Custom Apprise URL', () => { + const url = fillAndSubmit('Custom Apprise URL', { + apprise_url: 'tgram://mybot/mychat/', + }); + expect(url).toBe('tgram://mybot/mychat/'); + }); + }); + + // ── Edit mode (initialData) ──────────────────────────────────────────── + + describe('Edit mode with initialData', () => { + const initialData = { + name: 'My Existing Alert', + channel: 'telegram', + apprise_url: 'tgram://existingbot/existingchat/', + notify_on_errors: true, + notify_on_success: true, + }; + + it('should start on step 3 when initialData is provided', () => { + render( + + ); + expect(screen.getByText('Final Setup')).toBeInTheDocument(); + }); + + it('should pre-fill the name from initialData', () => { + render( + + ); + const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert') as HTMLInputElement; + expect(nameInput.value).toBe('My Existing Alert'); + }); + + it('should pre-set notification preferences from initialData', () => { + render( + + ); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).toBeChecked(); // notify_on_errors + expect(checkboxes[1]).toBeChecked(); // notify_on_success + }); + }); +}); diff --git a/frontend/src/components/ProviderWizard.test.tsx b/frontend/src/components/ProviderWizard.test.tsx new file mode 100644 index 0000000..1b129a6 --- /dev/null +++ b/frontend/src/components/ProviderWizard.test.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ProviderWizard } from './ProviderWizard'; + +// Mock next/image +jest.mock('next/image', () => { + return function MockImage({ alt, ...props }: { alt: string; [key: string]: unknown }) { + // eslint-disable-next-line @next/next/no-img-element + return {alt}; + }; +}); + +// Mock lucide-react icons +jest.mock('lucide-react', () => ({ + Mail: ({ className }: { className?: string }) => , + ArrowLeft: ({ className }: { className?: string }) => , +})); + +const mockOnSelect = jest.fn(); +const mockOnManual = jest.fn(); + +describe('ProviderWizard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── Provider List ────────────────────────────────────────────────────── + + describe('Provider List', () => { + it('should render all email providers', () => { + render(); + expect(screen.getByText('Gmail')).toBeInTheDocument(); + expect(screen.getByText('GMX')).toBeInTheDocument(); + expect(screen.getByText('WEB.DE')).toBeInTheDocument(); + expect(screen.getByText('Outlook / Hotmail')).toBeInTheDocument(); + expect(screen.getByText('Yahoo Mail')).toBeInTheDocument(); + expect(screen.getByText('AOL Mail')).toBeInTheDocument(); + expect(screen.getByText('T-Online')).toBeInTheDocument(); + expect(screen.getByText('1&1 / IONOS')).toBeInTheDocument(); + expect(screen.getByText('Freenet')).toBeInTheDocument(); + expect(screen.getByText('iCloud Mail')).toBeInTheDocument(); + expect(screen.getByText('Posteo')).toBeInTheDocument(); + expect(screen.getByText('Proton Mail')).toBeInTheDocument(); + }); + + it('should show the quick setup heading', () => { + render(); + expect(screen.getByText('Quick Setup — Select Your Email Provider')).toBeInTheDocument(); + }); + + it('should show Configure Manually button', () => { + render(); + expect(screen.getByText('Configure Manually')).toBeInTheDocument(); + }); + + it('should call onManual when Configure Manually is clicked', () => { + render(); + fireEvent.click(screen.getByText('Configure Manually')); + expect(mockOnManual).toHaveBeenCalled(); + }); + }); + + // ── Provider Detail ──────────────────────────────────────────────────── + + describe('Provider Detail (after selecting a provider)', () => { + it('should show provider details when Gmail is selected', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + expect(screen.getByText(/gmail\.com, googlemail\.com/)).toBeInTheDocument(); + expect(screen.getByText(/Enable IMAP\/POP3 in Gmail settings/)).toBeInTheDocument(); + }); + + it('should show protocol selection buttons', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument(); + expect(screen.getByText('POP3')).toBeInTheDocument(); + }); + + it('should show IMAP server details for Gmail', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + expect(screen.getByText('imap.gmail.com:993')).toBeInTheDocument(); + expect(screen.getByText('pop.gmail.com:995')).toBeInTheDocument(); + }); + + it('should show Back to providers button', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + expect(screen.getByText('Back to providers')).toBeInTheDocument(); + }); + + it('should go back to provider list when Back is clicked', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + fireEvent.click(screen.getByText('Back to providers')); + expect(screen.getByText('Quick Setup — Select Your Email Provider')).toBeInTheDocument(); + }); + + it('should show Use button with provider name', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + expect(screen.getByText('Use Gmail Settings')).toBeInTheDocument(); + }); + }); + + // ── Provider Selection Callback ──────────────────────────────────────── + + describe('onSelect callback', () => { + it('should call onSelect with IMAP config when IMAP is chosen for Gmail', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + // IMAP is selected by default + fireEvent.click(screen.getByText('Use Gmail Settings')); + expect(mockOnSelect).toHaveBeenCalledWith({ + name: 'Gmail', + provider_name: 'Gmail', + protocol: 'imap_ssl', + host: 'imap.gmail.com', + port: 993, + use_ssl: true, + }); + }); + + it('should call onSelect with POP3 config when POP3 is chosen for Gmail', () => { + render(); + fireEvent.click(screen.getByText('Gmail')); + fireEvent.click(screen.getByText('POP3')); + fireEvent.click(screen.getByText('Use Gmail Settings')); + expect(mockOnSelect).toHaveBeenCalledWith({ + name: 'Gmail', + provider_name: 'Gmail', + protocol: 'pop3_ssl', + host: 'pop.gmail.com', + port: 995, + use_ssl: true, + }); + }); + + it('should call onSelect with correct config for Outlook', () => { + render(); + fireEvent.click(screen.getByText('Outlook / Hotmail')); + fireEvent.click(screen.getByText('Use Outlook / Hotmail Settings')); + expect(mockOnSelect).toHaveBeenCalledWith({ + name: 'Outlook / Hotmail', + provider_name: 'Outlook / Hotmail', + protocol: 'imap_ssl', + host: 'outlook.office365.com', + port: 993, + use_ssl: true, + }); + }); + + it('should call onSelect with correct config for GMX', () => { + render(); + fireEvent.click(screen.getByText('GMX')); + fireEvent.click(screen.getByText('Use GMX Settings')); + expect(mockOnSelect).toHaveBeenCalledWith({ + name: 'GMX', + provider_name: 'GMX', + protocol: 'imap_ssl', + host: 'imap.gmx.net', + port: 993, + use_ssl: true, + }); + }); + }); + + // ── IMAP-only providers ──────────────────────────────────────────────── + + describe('IMAP-only providers', () => { + it('should only show IMAP option for iCloud', () => { + render(); + fireEvent.click(screen.getByText('iCloud Mail')); + expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument(); + expect(screen.queryByText('POP3')).not.toBeInTheDocument(); + }); + + it('should only show IMAP option for Posteo', () => { + render(); + fireEvent.click(screen.getByText('Posteo')); + expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument(); + expect(screen.queryByText('POP3')).not.toBeInTheDocument(); + }); + + it('should auto-select IMAP for iCloud and call onSelect correctly', () => { + render(); + fireEvent.click(screen.getByText('iCloud Mail')); + fireEvent.click(screen.getByText('Use iCloud Mail Settings')); + expect(mockOnSelect).toHaveBeenCalledWith({ + name: 'iCloud Mail', + provider_name: 'iCloud Mail', + protocol: 'imap_ssl', + host: 'imap.mail.me.com', + port: 993, + use_ssl: true, + }); + }); + }); + + // ── Proton Mail (Bridge) ─────────────────────────────────────────────── + + describe('Proton Mail (Bridge)', () => { + it('should show Proton Mail Bridge notes', () => { + render(); + fireEvent.click(screen.getByText('Proton Mail')); + expect(screen.getByText(/Requires Proton Mail Bridge/)).toBeInTheDocument(); + }); + + it('should show localhost connection details', () => { + render(); + fireEvent.click(screen.getByText('Proton Mail')); + expect(screen.getByText('127.0.0.1:1143')).toBeInTheDocument(); + expect(screen.getByText('127.0.0.1:1144')).toBeInTheDocument(); + }); + + it('should call onSelect with localhost config', () => { + render(); + fireEvent.click(screen.getByText('Proton Mail')); + fireEvent.click(screen.getByText('Use Proton Mail Settings')); + expect(mockOnSelect).toHaveBeenCalledWith({ + name: 'Proton Mail', + provider_name: 'Proton Mail', + protocol: 'imap_ssl', + host: '127.0.0.1', + port: 1143, + use_ssl: true, + }); + }); + }); +}); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 8778b2d..5c51f3e 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -45,6 +45,7 @@ jest.mock('axios', () => ({ })); // Force module initialization to capture interceptors +// eslint-disable-next-line @typescript-eslint/no-require-imports require('./api'); describe('API module setup', () => {