Phase 5.4-5.11: Playwright E2E test infrastructure with 7 spec files
- playwright.config.ts: chromium project, webServer, baseURL, trace/screenshot/video - e2e/helpers.ts: API mock setup, login/logout helpers, mock data for all entities - 7 E2E spec files (1164 lines total): - auth.spec.ts: login/logout workflow - contact-crud.spec.ts: create/edit/delete contacts, add persons - search.spec.ts: global search, filter, results - plugin-toggle.spec.ts: enable/disable plugins, UI changes - mail.spec.ts: mail account setup, folders, mail viewing - dms.spec.ts: folder creation, file upload, preview, share - calendar.spec.ts: appointment creation, calendar switch, kanban view - @playwright/test added as devDependency - e2e scripts added to package.json - TSC: 0 new errors (only pre-existing Dms.tsx)
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user