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, }, ]; export const MOCK_MAIL_FOLDERS = [ { id: 'folder-001', name: 'INBOX', unread_count: 3, account_id: 'acc-001' }, { id: 'folder-002', name: 'Sent', unread_count: 0, account_id: 'acc-001' }, { id: 'folder-003', name: 'Drafts', unread_count: 0, account_id: 'acc-001' }, ]; export const MOCK_MAILS = [ { id: 'mail-001', subject: 'Welcome to LeoCRM', from: 'noreply@leocrm.test', to: 'test@leocrm.test', date: '2026-07-23T10:00:00Z', body_text: 'Welcome to LeoCRM! Your account is ready.', is_read: false, flags: [], attachments: [], }, { id: 'mail-002', subject: 'Meeting Tomorrow', from: 'boss@leocrm.test', to: 'test@leocrm.test', date: '2026-07-23T09:00:00Z', body_text: 'Don\'t forget the meeting tomorrow at 10 AM.', is_read: true, flags: [], attachments: [], }, ]; 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) { // Auth: login await page.route('**/api/v1/auth/login', (route) => { 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', }), }); }); // Auth: me (current user) await page.route('**/api/v1/auth/me', (route) => { 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, }), }); }); // Auth: logout await page.route('**/api/v1/auth/logout', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); }); // Contacts list await page.route('**/api/v1/contacts*', (route) => { const url = route.request().url(); if (url.includes('/contact_persons')) { route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); return; } if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CONTACTS), }); } else if (route.request().method() === 'POST') { const body = route.request().postDataJSON(); route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'contact-new', contact_persons: [], displayname: body?.name || `${body?.firstname || ''} ${body?.surname || ''}`.trim(), ...body, }), }); } else { route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } }); // Contact detail await page.route('**/api/v1/contacts/*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CONTACTS.items[0]), }); } else if (route.request().method() === 'PUT') { const body = route.request().postDataJSON(); route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...MOCK_CONTACTS.items[0], ...body }), }); } else if (route.request().method() === 'DELETE') { route.fulfill({ status: 204, body: '' }); } else { route.continue(); } }); // Mail accounts await page.route('**/api/v1/mail/accounts*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MAIL_ACCOUNTS), }); } else if (route.request().method() === 'POST') { const body = route.request().postDataJSON(); route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'acc-new', display_name: body?.display_name || body?.email, ...body, }), }); } else { route.continue(); } }); // Mail folders await page.route('**/api/v1/mail/accounts/*/folders*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MAIL_FOLDERS), }); }); // Mail list await page.route('**/api/v1/mail/accounts/*/mails*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: MOCK_MAILS, total: MOCK_MAILS.length }), }); } else { route.continue(); } }); // Mail detail await page.route('**/api/v1/mail/*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MAILS[0]), }); } else { route.continue(); } }); // DMS folders await page.route('**/api/v1/dms/folders*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DMS_FOLDERS), }); } else if (route.request().method() === 'POST') { const body = route.request().postDataJSON(); route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'folder-new', name: body?.name, parent_id: body?.parent_id ?? null }), }); } else { route.continue(); } }); // DMS files await page.route('**/api/v1/dms/files*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DMS_FILES), }); } else { route.continue(); } }); // DMS shared files await page.route('**/api/v1/dms/shared*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); }); // Calendar list await page.route('**/api/v1/calendar/calendars*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CALENDARS), }); } else { route.continue(); } }); // Calendar entries await page.route('**/api/v1/calendar/entries*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CALENDAR_ENTRIES), }); } else if (route.request().method() === 'POST') { const body = route.request().postDataJSON(); route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'entry-new', ...body }), }); } else { route.continue(); } }); // Calendar kanban await page.route('**/api/v1/calendar/kanban*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ open: MOCK_CALENDAR_ENTRIES, in_progress: [], done: [], cancelled: [], }), }); }); // Plugins await page.route('**/api/v1/plugins*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PLUGINS), }); } else if (route.request().method() === 'POST') { route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } else { route.continue(); } }); // Plugin activate/deactivate await page.route('**/api/v1/plugins/*/activate', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); }); await page.route('**/api/v1/plugins/*/deactivate', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); }); // Search await page.route('**/api/v1/search*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_SEARCH_RESULTS), }); }); // User preferences await page.route('**/api/v1/user/preferences*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); }); // Generic catch-all for other API endpoints await page.route('**/api/v1/**', (route) => { if (!route.request().url().includes('/auth/') && !route.request().url().includes('/contacts') && !route.request().url().includes('/mail/') && !route.request().url().includes('/dms/') && !route.request().url().includes('/calendar/') && !route.request().url().includes('/plugins') && !route.request().url().includes('/search')) { route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); } else { route.continue(); } }); } /** * Perform login via the login form. * Assumes API mocks are already set up. */ export async function login(page: Page) { await page.goto('/login'); await expect(page.locator('[data-testid="login-page"]')).toBeVisible(); // Fill email and password using label-based selectors await page.locator('input[type="email"]').fill(TEST_USER.email); await page.locator('input[type="password"]').fill(TEST_USER.password); // Submit the form await page.locator('button[type="submit"]').click(); // Wait for redirect to dashboard await page.waitForURL('**/dashboard', { timeout: 10_000 }); await expect(page.locator('[data-testid="topbar"]')).toBeVisible(); } /** * Perform logout via the user menu. */ export async function logout(page: Page) { // Open user menu await page.locator('[data-testid="topbar"] button[aria-haspopup="menu"]').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'); }