import { Page, expect } from '@playwright/test'; /** * E2E test helpers for LeoCRM. * * These helpers provide login automation, API mocking, and common * fixtures used across all spec files. Since the container has no * PostgreSQL/Redis, API calls are intercepted with mock responses. */ // ── Test credentials ── export const TEST_USER = { email: 'admin@leocrm.test', password: 'TestPass123!', firstName: 'Admin', lastName: 'User', id: 'user-001', role: 'system_admin', }; export const TEST_TENANT = { id: 'tenant-001', name: 'Test Tenant', slug: 'test-tenant', }; // ── Mock data ── export const MOCK_CONTACTS = { items: [ { id: 'contact-001', type: 'company' as const, name: 'TechCorp GmbH', displayname: 'TechCorp GmbH', code: 'K-00123', email_1: 'info@techcorp.test', phone_1: '+49 30 12345678', mailing_city: 'Berlin', mailing_postalcode: '10115', mailing_street: 'Hauptstrasse', mailing_number: '1', tags: 'kunde, premium', contact_persons: [], }, { id: 'contact-002', type: 'person' as const, firstname: 'Max', surname: 'Mustermann', displayname: 'Max Mustermann', email_1: 'max@mustermann.test', phone_1: '+49 170 9876543', contact_persons: [], }, ], total: 2, }; export const MOCK_MAIL_ACCOUNTS = [ { id: 'acc-001', email: 'test@leocrm.test', display_name: 'Test Account', imap_host: 'imap.test.test', imap_port: 993, smtp_host: 'smtp.test.test', smtp_port: 587, is_shared: false, is_active: true, }, ]; export const MOCK_MAIL_FOLDERS = [ { id: 'folder-001', name: 'INBOX', imap_name: 'INBOX', parent_id: null, unread_count: 3, total_count: 2, account_id: 'acc-001' }, { id: 'folder-002', name: 'Sent', imap_name: 'Sent', parent_id: null, unread_count: 0, total_count: 0, account_id: 'acc-001' }, { id: 'folder-003', name: 'Drafts', imap_name: 'Drafts', parent_id: null, unread_count: 0, total_count: 0, account_id: 'acc-001' }, ]; export const MOCK_MAILS = [ { id: 'mail-001', folder_id: 'folder-001', account_id: 'acc-001', subject: 'Welcome to LeoCRM', from_address: 'noreply@leocrm.test', from_name: 'LeoCRM', to_addresses: ['test@leocrm.test'], cc_addresses: [], bcc_addresses: [], date: '2026-07-23T10:00:00Z', body_text: 'Welcome to LeoCRM! Your account is ready.', body_html: null, sanitized_html: null, is_seen: false, is_flagged: false, flag_type: null, is_draft: false, is_answered: false, has_attachments: false, attachments: [], labels: [], }, { id: 'mail-002', folder_id: 'folder-001', account_id: 'acc-001', subject: 'Meeting Tomorrow', from_address: 'boss@leocrm.test', from_name: 'Boss', to_addresses: ['test@leocrm.test'], cc_addresses: [], bcc_addresses: [], date: '2026-07-23T09:00:00Z', body_text: 'Don\'t forget the meeting tomorrow at 10 AM.', body_html: null, sanitized_html: null, is_seen: true, is_flagged: false, flag_type: null, is_draft: false, is_answered: false, has_attachments: false, attachments: [], labels: [], }, ]; export const MOCK_DMS_FOLDERS = [ { id: 'folder-001', name: 'Documents', parent_id: null }, { id: 'folder-002', name: 'Images', parent_id: null }, { id: 'folder-003', name: 'Contracts', parent_id: 'folder-001' }, ]; export const MOCK_DMS_FILES = [ { id: 'file-001', name: 'contract.pdf', folder_id: 'folder-001', size: 102400, mime_type: 'application/pdf', created_at: '2026-07-23T10:00:00Z', updated_at: '2026-07-23T10:00:00Z', }, { id: 'file-002', name: 'photo.jpg', folder_id: 'folder-002', size: 204800, mime_type: 'image/jpeg', created_at: '2026-07-23T11:00:00Z', updated_at: '2026-07-23T11:00:00Z', }, ]; export const MOCK_CALENDARS = [ { id: 'cal-001', name: 'Personal', color: '#3b82f6' }, { id: 'cal-002', name: 'Work', color: '#10b981' }, ]; export const MOCK_CALENDAR_ENTRIES = [ { id: 'entry-001', calendar_id: 'cal-001', entry_type: 'appointment', title: 'Team Meeting', description: 'Weekly team sync', start_at: '2026-07-24T09:00:00Z', end_at: '2026-07-24T10:00:00Z', all_day: false, priority: 'medium', status: 'open', subtype: 'normal', }, ]; export const MOCK_PLUGINS = [ { name: 'tags', display_name: 'Tags', description: 'Tag management plugin', version: '1.0.0', active: true, installed: true, status: 'active', }, { name: 'entity_links', display_name: 'Entity Links', description: 'Link entities together', version: '1.0.0', active: false, installed: true, status: 'inactive', }, ]; export const MOCK_SEARCH_RESULTS = { contact: [ { id: 'contact-001', type: 'contact', title: 'TechCorp GmbH', snippet: 'Company in Berlin' }, ], company: [], mail: [ { id: 'mail-001', type: 'mail', title: 'Welcome to LeoCRM', snippet: 'Welcome email' }, ], file: [], event: [], }; // ── API mock setup ── /** * Intercept all API calls with mock responses. * Call this in beforeEach to set up a fully mocked environment. */ export async function setupApiMocks(page: Page) { await page.route('**/api/v1/**', (route) => { const req = route.request(); const url = new URL(req.url()); const method = req.method(); const pathname = url.pathname; // Auth endpoints if (pathname === '/api/v1/auth/login') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: TEST_USER.id, email: TEST_USER.email, first_name: TEST_USER.firstName, last_name: TEST_USER.lastName, role: TEST_USER.role, tenants: [TEST_TENANT], permissions: ['*'], is_system_admin: true, }, csrf_token: 'mock-csrf-token', }), }); } if (pathname === '/api/v1/auth/me') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: TEST_USER.id, email: TEST_USER.email, first_name: TEST_USER.firstName, last_name: TEST_USER.lastName, role: TEST_USER.role, tenants: [TEST_TENANT], permissions: ['*'], is_system_admin: true, }), }); } if (pathname === '/api/v1/auth/logout') { return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } // Contacts if (pathname.startsWith('/api/v1/contacts')) { if (pathname.includes('/contact_persons')) { return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); } // Contact detail: /api/v1/contacts/ const contactsDetailMatch = pathname.match(/^\/api\/v1\/contacts\/([^/]+)$/); if (contactsDetailMatch && method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...MOCK_CONTACTS.items[0], id: contactsDetailMatch[1], contact_persons: [] }), }); } if (method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CONTACTS), }); } if (method === 'POST') { const body = req.postDataJSON() || {}; return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'contact-new', contact_persons: [], displayname: body.name || `${body.firstname || ''} ${body.surname || ''}`.trim(), ...body, }), }); } if (method === 'PUT') { const body = req.postDataJSON() || {}; const id = pathname.split('/').pop() || 'contact-001'; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...MOCK_CONTACTS.items[0], id, ...body }), }); } if (method === 'DELETE') { return route.fulfill({ status: 204, body: '' }); } return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } // Mail if (pathname === '/api/v1/mail/accounts') { if (method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MAIL_ACCOUNTS) }); } const body = req.postDataJSON() || {}; return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'acc-new', display_name: body.display_name || body.email, ...body }), }); } if (pathname === '/api/v1/mail/folders') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MAIL_FOLDERS) }); } if (pathname === '/api/v1/mail') { // fetchMails — /api/v1/mail?folder_id=... return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ mails: MOCK_MAILS, total: MOCK_MAILS.length, page: 1, page_size: 25 }), }); } if (pathname === '/api/v1/mail/search') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ mails: MOCK_MAILS, total: MOCK_MAILS.length, page: 1, page_size: 25 }), }); } if (pathname.startsWith('/api/v1/mail/signatures')) { return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); } const singleMailMatch = pathname.match(/^\/api\/v1\/mail\/([^/]+)$/); if (singleMailMatch && method === 'GET') { const mailId = singleMailMatch[1]; const mail = MOCK_MAILS.find((m) => m.id === mailId) || MOCK_MAILS[0]; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mail) }); } if (pathname.startsWith('/api/v1/mail/')) { return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } // DMS if (pathname === '/api/v1/dms/folders') { if (method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DMS_FOLDERS) }); } const body = req.postDataJSON() || {}; return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'folder-new', name: body.name, parent_id: body.parent_id ?? null }), }); } if (pathname === '/api/v1/dms/files') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DMS_FILES) }); } if (pathname.startsWith('/api/v1/dms/')) { return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); } // Calendar if (pathname === '/api/v1/calendars') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CALENDARS) }); } if (pathname === '/api/v1/calendar/entries') { if (method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CALENDAR_ENTRIES) }); } const body = req.postDataJSON() || {}; return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'entry-new', ...body }) }); } if (pathname === '/api/v1/calendar/kanban') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ open: MOCK_CALENDAR_ENTRIES, in_progress: [], done: [], cancelled: [], }), }); } // Plugins if (pathname === '/api/v1/plugins/active-manifests' || pathname === '/api/v1/plugins') { if (method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plugins: MOCK_PLUGINS, total: MOCK_PLUGINS.length }), }); } return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } const pluginToggleMatch = pathname.match(/^\/api\/v1\/plugins\/([^/]+)\/(activate|deactivate)$/); if (pluginToggleMatch) { const [, slug, action] = pluginToggleMatch; const plugin = MOCK_PLUGINS.find((p) => p.name === slug); if (plugin) { plugin.active = action === 'activate'; plugin.status = action === 'activate' ? 'active' : 'inactive'; } return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } // Search if (pathname === '/api/v1/search') { if (method === 'POST') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ results: [ { type: 'contact', id: 'contact-001', name: 'TechCorp GmbH', description: 'Company in Berlin', url: '/contacts/contact-001' }, { type: 'mail', id: 'mail-001', name: 'Welcome to LeoCRM', description: 'Welcome email', url: '/mail/mail-001' }, ], facets: {}, summary: '2 results', }), }); } return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ results: [], facets: {}, summary: '' }) }); } // User preferences if (pathname.startsWith('/api/v1/user/preferences')) { return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } // Fallback: empty array/object for any other API call return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); }); } /** * Perform login via the login form.} /** * Perform login via the login form. * Assumes API mocks are already set up. */ export async function login(page: Page) { // Set auth state in localStorage before navigating to the SPA. // The authStore uses Zustand persist middleware with key 'auth-store'. // This ensures auth state survives page navigation (page.goto reloads the JS context). const authState = { state: { user: { id: TEST_USER.id, email: TEST_USER.email, first_name: TEST_USER.firstName, last_name: TEST_USER.lastName, role: TEST_USER.role, tenants: [TEST_TENANT], permissions: ['*'], is_system_admin: true, avatar_url: null, field_permissions: {}, }, currentTenant: TEST_TENANT, isAuthenticated: true, }, version: 0, }; // Go to login page first (public route, always accessible) await page.goto('/login'); await page.evaluate((state) => { localStorage.setItem('auth-store', JSON.stringify(state)); // Dismiss welcome dialog by marking onboarding as completed localStorage.setItem('leocrm_onboarding', JSON.stringify({ step: 0, completed: true, skipped: false })); }, authState); // Navigate to /start — Zustand persist will restore auth state from localStorage await page.goto('/start'); await page.waitForLoadState('domcontentloaded'); await expect(page.locator('[data-testid="topbar"]')).toBeVisible({ timeout: 10_000 }); // Ensure welcome dialog is dismissed (in case it still appears) const welcomeDialog = page.locator('[data-testid="welcome-dialog"]'); if (await welcomeDialog.isVisible({ timeout: 2_000 }).catch(() => false)) { await welcomeDialog.locator('button').filter({ hasText: /überspringen|skip/i }).click().catch(() => {}); await page.waitForTimeout(500); } } /** * Perform logout via the user menu. */ export async function logout(page: Page) { // Open user menu (use aria-label to distinguish from notification button) await page.locator('[data-testid="topbar"] button[aria-label="Benutzermenü"]').click(); // Click logout button (text-based since no data-testid on logout button) await page.locator('[role="menuitem"]').filter({ hasText: /logout|abmelden/i }).click(); await page.waitForURL('*/login', { timeout: 10_000 }); } /** * Navigate to a specific route after login. */ export async function navigateTo(page: Page, path: string) { await page.goto(path); await page.waitForLoadState('networkidle'); }