Files
leocrm/frontend/e2e/helpers.ts
T
Agent Zero b60500d455 test: Fix E2E Playwright tests (25/34 pass) + cleanup old briefings
E2E Test Fixes (12 tests fixed, 25/34 now pass):
- helpers.ts: Fix API mock routes, response shapes, welcome dialog dismissal
- auth.spec.ts: Fix logout selector (duplicate button match)
- calendar.spec.ts: Fix strict mode violations, modal close assertions
- dms.spec.ts: Fix strict mode violations, modal close assertions
- contact-crud.spec.ts: Fix modal close assertion
- mail.spec.ts: Fix modal close assertion
- search.spec.ts: Fix search result expectations, empty query test

9 remaining failures: Playwright route interception with glob patterns
does not match when Vite dev proxy is configured (calendar/mail/plugins).

Cleanup:
- Delete 13 old .a0/briefings/ files
- Delete test-results/, docs/test_raw_output.md, e2e_test_report.md, test_report.md
- Delete .a0/known_errors.md (circuit breaker bug is fixed)
2026-08-04 20:53:51 +02:00

587 lines
17 KiB
TypeScript

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 signatures
await page.route('**/api/v1/mail/signatures*', (route) => {
if (route.request().method() === 'GET') {
route.fulfill({ status: 200, contentType: 'application/json', 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 (API calls /mail/folders?account_id=...)
await page.route('**/api/v1/mail/folders*', (route) => {
if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_MAIL_FOLDERS),
});
} else {
route.continue();
}
});
// Mail list (API calls /mail/mails?account_id=...&folder_id=...)
await page.route('**/api/v1/mail/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 (API calls /calendars, not /calendar/calendars)
await page.route('**/api/v1/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) => {
const url = route.request().url();
if (url.includes('/active-manifests')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plugins: MOCK_PLUGINS, total: MOCK_PLUGINS.length }),
});
return;
}
if (route.request().method() === 'GET') {
// GET /plugins returns { plugins: Plugin[], total: number }
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plugins: MOCK_PLUGINS, total: MOCK_PLUGINS.length }),
});
} 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 (API uses POST to /search)
await page.route('**/api/v1/search*', (route) => {
if (route.request().method() === 'POST') {
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' }),
});
} else if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [], facets: {}, summary: '' }),
});
} else {
route.continue();
}
});
// 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) {
// 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');
}