From fdabd2e74cab03b21d859db63a238278405ea372 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 7 Aug 2026 22:03:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20E2E-Test-Suite=20vollst=C3=A4n?= =?UTF-8?q?dig=20gr=C3=BCn=20machen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Robuster gegen undefined API-Daten in Mail, ContactDetail, ContactsList, Settings, Sidebar - E2E-Mocks korrigiert für Kontakt-Detail, Mail-Liste/Folders und Plugin-Toggle - Auth-Store mit persist-Middleware für E2E-Login - test-results/ in .gitignore aufgenommen Playwright E2E: 34/34 passed --- frontend/.gitignore | 1 + frontend/e2e/helpers.ts | 556 ++++++++---------- frontend/e2e/mail.spec.ts | 13 +- frontend/e2e/plugin-toggle.spec.ts | 2 +- frontend/e2e/search.spec.ts | 2 +- .../src/components/contacts/ContactDetail.tsx | 11 +- frontend/src/components/layout/Sidebar.tsx | 6 +- frontend/src/components/mail/MailDetail.tsx | 6 +- .../src/components/mail/MailFilterPanel.tsx | 10 +- .../src/components/mail/MailFolderTree.tsx | 2 +- .../src/components/mail/MailGroupPanel.tsx | 4 +- frontend/src/components/mail/MailList.tsx | 14 +- .../src/components/mail/MailSortPanel.tsx | 4 +- frontend/src/pages/ContactsList.tsx | 14 +- frontend/src/pages/Mail.tsx | 34 +- frontend/src/pages/Settings.tsx | 5 +- frontend/src/pages/SettingsPlugins.tsx | 4 +- frontend/src/store/authStore.ts | 81 +-- 18 files changed, 368 insertions(+), 401 deletions(-) diff --git a/frontend/.gitignore b/frontend/.gitignore index 2c682cf..1891d0d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -7,3 +7,4 @@ dist/ coverage/ .vitest/ *.log +test-results/ diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index 620149d..ee7930c 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -66,37 +66,62 @@ export const MOCK_MAIL_ACCOUNTS = [ smtp_host: 'smtp.test.test', smtp_port: 587, is_shared: false, + is_active: true, }, ]; 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' }, + { 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: 'noreply@leocrm.test', - to: 'test@leocrm.test', + 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.', - is_read: false, - flags: [], + 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: 'boss@leocrm.test', - to: 'test@leocrm.test', + 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.', - is_read: true, - flags: [], + 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: [], }, ]; @@ -188,13 +213,38 @@ export const MOCK_SEARCH_RESULTS = { * 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: { + 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, @@ -203,320 +253,214 @@ export async function setupApiMocks(page: Page) { 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(); + if (pathname === '/api/v1/auth/logout') { + return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } - }); - // 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(); + // 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 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({ + // 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({ - id: 'acc-new', - display_name: body?.display_name || body?.email, - ...body, + open: MOCK_CALENDAR_ENTRIES, + in_progress: [], + done: [], + cancelled: [], }), }); - } 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(); + // 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: '{}' }); } - }); - // 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(); + 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: '{}' }); } - }); - // 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(); + // 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: '' }) }); } - }); - // 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(); + // User preferences + if (pathname.startsWith('/api/v1/user/preferences')) { + return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); } - }); - // 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(); - } + // 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. diff --git a/frontend/e2e/mail.spec.ts b/frontend/e2e/mail.spec.ts index 1ee1761..8326775 100644 --- a/frontend/e2e/mail.spec.ts +++ b/frontend/e2e/mail.spec.ts @@ -11,14 +11,15 @@ test.describe('Mail E2E', () => { await navigateTo(page, '/mail'); // Mail page should load - check for folder tree or loading state - await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]')).toBeVisible({ timeout: 15_000 }); + // .first() because the responsive layout renders the tree in both desktop and mobile panes. + await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]').first()).toBeVisible({ timeout: 15_000 }); }); test('mail list shows emails when folder is selected', async ({ page }) => { await navigateTo(page, '/mail'); // Wait for folder tree - await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]').first()).toBeVisible({ timeout: 15_000 }); // Click on first folder if available const folder = page.locator('[data-testid^="folder-"]').first(); @@ -26,7 +27,7 @@ test.describe('Mail E2E', () => { await folder.click(); // Mail list should appear - await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]').first()).toBeVisible({ timeout: 10_000 }); } }); @@ -34,14 +35,14 @@ test.describe('Mail E2E', () => { await navigateTo(page, '/mail'); // Wait for folder tree and click first folder - await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]').first()).toBeVisible({ timeout: 15_000 }); const folder = page.locator('[data-testid^="folder-"]').first(); if (await folder.isVisible({ timeout: 5_000 })) { await folder.click(); // Wait for mail list - await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]').first()).toBeVisible({ timeout: 10_000 }); // Click first mail if available const mailItem = page.locator('[data-testid^="mail-item-"]').first(); @@ -49,7 +50,7 @@ test.describe('Mail E2E', () => { await mailItem.click(); // Mail detail should appear - await expect(page.locator('[data-testid="mail-detail"], [data-testid="mail-detail-loading"], [data-testid="mail-detail-empty"]')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="mail-detail"], [data-testid="mail-detail-loading"], [data-testid="mail-detail-empty"]').first()).toBeVisible({ timeout: 10_000 }); } } }); diff --git a/frontend/e2e/plugin-toggle.spec.ts b/frontend/e2e/plugin-toggle.spec.ts index 9a077fc..1194fb9 100644 --- a/frontend/e2e/plugin-toggle.spec.ts +++ b/frontend/e2e/plugin-toggle.spec.ts @@ -62,6 +62,6 @@ test.describe('Plugin Toggle E2E', () => { await expect(page.locator('[data-testid="settings-plugins-page"]')).toBeVisible({ timeout: 10_000 }); // Upload and URL install options should be present - await expect(page.locator('[data-testid="plugin-zip-upload-input"], [data-testid="plugin-upload-btn"], [data-testid="plugin-url-input"], [data-testid="plugin-install-url-btn"]')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="plugin-zip-upload-input"], [data-testid="plugin-upload-btn"], [data-testid="plugin-url-input"], [data-testid="plugin-install-url-btn"]').first()).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/frontend/e2e/search.spec.ts b/frontend/e2e/search.spec.ts index 39bc39c..e0065bd 100644 --- a/frontend/e2e/search.spec.ts +++ b/frontend/e2e/search.spec.ts @@ -25,7 +25,7 @@ test.describe('Global Search E2E', () => { await page.locator('[data-testid="search-submit-btn"]').click(); // Wait for results - await expect(page.locator('[data-testid="search-results-list"], [data-testid="search-results-all"]')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="search-results-list"], [data-testid="search-results-all"]').first()).toBeVisible({ timeout: 10_000 }); // Should show at least one result (or empty state if mock doesn't match exactly) const resultItem = page.locator('[data-testid^="search-result-"]').first(); diff --git a/frontend/src/components/contacts/ContactDetail.tsx b/frontend/src/components/contacts/ContactDetail.tsx index 94ff626..f798752 100644 --- a/frontend/src/components/contacts/ContactDetail.tsx +++ b/frontend/src/components/contacts/ContactDetail.tsx @@ -33,6 +33,7 @@ export interface ContactDetailProps { loading?: boolean; onEdit?: () => void; onDeleted: () => void; + dataTestId?: string; } function Field({ label, value, fieldName }: { label: string; value?: string | null; fieldName?: string }) { @@ -156,7 +157,7 @@ function getIcon(name: string): React.ReactNode { return Icon ? : ; } -export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) { +export function ContactDetail({ contact, loading, onEdit, onDeleted, dataTestId = 'contact-detail' }: ContactDetailProps) { const { t } = useTranslation(); const toast = useToast(); const deleteMutation = useDeleteUnifiedContact(); @@ -173,9 +174,9 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe const [activeTab, setActiveTab] = useState('details'); const manifests = usePluginStore(s => s.manifests); const pluginTabs = useMemo( - () => manifests - .flatMap((m) => m.detail_tabs) - .filter((t) => t.entity_type === 'contact') + () => (manifests || []) + .flatMap((m) => (Array.isArray(m.detail_tabs) ? m.detail_tabs : [])) + .filter((t): t is NonNullable => !!t && t.entity_type === 'contact') .sort((a, b) => a.order - b.order), [manifests] ); @@ -283,7 +284,7 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe ]; return ( -
+
{/* Header */}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index b760bbe..3a88188 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -87,9 +87,9 @@ export function Sidebar() { permission: item.to === '/dashboard' ? 'dashboard:read' : item.to === '/contacts' ? 'contacts:read' : undefined, })); - const pluginItems = manifests - .flatMap((m) => m.menu_items) - .filter(item => !item.permission || canAccess(item.permission)) + const pluginItems = (manifests || []) + .flatMap((m) => (Array.isArray(m.menu_items) ? m.menu_items : [])) + .filter((item): item is NonNullable => !!item && (!item.permission || canAccess(item.permission))) .map(item => ({ path: item.path, labelKey: item.label_key, diff --git a/frontend/src/components/mail/MailDetail.tsx b/frontend/src/components/mail/MailDetail.tsx index b4685ed..a289312 100644 --- a/frontend/src/components/mail/MailDetail.tsx +++ b/frontend/src/components/mail/MailDetail.tsx @@ -110,12 +110,12 @@ export function MailDetail({
{t('mail.to')}: - {mail.to_addresses.join(', ')} + {(mail.to_addresses ?? []).join(', ')}
- {mail.cc_addresses.length > 0 && ( + {mail.cc_addresses && mail.cc_addresses.length > 0 && (
{t('mail.cc')}: - {mail.cc_addresses.join(', ')} + {(mail.cc_addresses ?? []).join(', ')}
)}
diff --git a/frontend/src/components/mail/MailFilterPanel.tsx b/frontend/src/components/mail/MailFilterPanel.tsx index fb426b4..9733af2 100644 --- a/frontend/src/components/mail/MailFilterPanel.tsx +++ b/frontend/src/components/mail/MailFilterPanel.tsx @@ -151,7 +151,7 @@ function matchesCondition(mail: any, cond: FilterCondition): boolean { } export function applyFilters(mails: any[], filters: FilterState): any[] { - if (!filters.conditions.length) return mails; + if (!(filters.conditions?.length ?? 0)) return mails; if (filters.logic === 'AND') { return mails.filter((m) => filters.conditions.every((cond) => matchesCondition(m, cond))); } else { @@ -208,7 +208,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o setOpen(!open); }; - const activeCount = filters.conditions.length; + const activeCount = filters.conditions?.length ?? 0; const addCondition = () => { onFiltersChange({ @@ -248,7 +248,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o const handleSaveFilter = () => { if (!onSaveFilter) return; - if (filters.conditions.length === 0) return; + if ((filters.conditions?.length ?? 0) === 0) return; const name = window.prompt('Name für diesen Filter:', 'Mein Filter'); if (!name) return; onSaveFilter(name, { ...filters, conditions: filters.conditions.map((c) => ({ ...c })) }); @@ -345,7 +345,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o )} {/* Saved filters */} - {savedFilters.length > 0 && ( + {(savedFilters?.length ?? 0) > 0 && (
Gespeicherte Filter
@@ -366,7 +366,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o {/* Filter rows */}
- {filters.conditions.length === 0 && ( + {(filters.conditions?.length ?? 0) === 0 && (
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
diff --git a/frontend/src/components/mail/MailFolderTree.tsx b/frontend/src/components/mail/MailFolderTree.tsx index 2fbe9d3..8a3c17f 100644 --- a/frontend/src/components/mail/MailFolderTree.tsx +++ b/frontend/src/components/mail/MailFolderTree.tsx @@ -301,7 +301,7 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, ); } - if (accounts.length === 0) { + if ((accounts?.length ?? 0) === 0) { return ( { onGroupChange({ conditions: [...groupState.conditions, { id: newGroupId(), field: 'from' }] }); diff --git a/frontend/src/components/mail/MailList.tsx b/frontend/src/components/mail/MailList.tsx index 8d86b69..e7235d9 100644 --- a/frontend/src/components/mail/MailList.tsx +++ b/frontend/src/components/mail/MailList.tsx @@ -52,8 +52,8 @@ function GroupSection({ const hasSubGroups = group.subGroups && group.subGroups.length > 0; // Count total mails including subGroups const totalCount = hasSubGroups - ? group.subGroups!.reduce((sum, sub) => sum + sub.mails.length, 0) - : group.mails.length; + ? group.subGroups!.reduce((sum, sub) => sum + (sub.mails?.length ?? 0), 0) + : (group.mails?.length ?? 0); return (
  • @@ -110,7 +110,7 @@ export function MailList({ hasMore = false, }: MailListProps) { const { t } = useTranslation(); - const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id)); + const allSelected = (mails?.length ?? 0) > 0 && mails.every((m) => selectedMailIds.has(m.id)); const scrollRef = useRef(null); // Infinite scroll handler @@ -121,7 +121,7 @@ export function MailList({ } }; - if (loading && mails.length === 0) { + if (loading && (mails?.length ?? 0) === 0) { return (
    - {loading && mails.length > 0 && } + {loading && (mails?.length ?? 0) > 0 && }
  • {/* Mail list — infinite scroll with optional grouping */} @@ -254,7 +254,7 @@ export function MailList({ ) : ( mails.map((mail) => renderMailItem(mail)) )} - {loading && mails.length > 0 && ( + {loading && (mails?.length ?? 0) > 0 && (
  • diff --git a/frontend/src/components/mail/MailSortPanel.tsx b/frontend/src/components/mail/MailSortPanel.tsx index f0e36d2..df3d565 100644 --- a/frontend/src/components/mail/MailSortPanel.tsx +++ b/frontend/src/components/mail/MailSortPanel.tsx @@ -60,7 +60,7 @@ function compareValues(a: any, b: any, fieldType: FieldType): number { } export function applySorting(mails: any[], sortState: SortState): any[] { - if (!sortState.conditions.length) return mails; + if (!(sortState.conditions?.length ?? 0)) return mails; const sorted = [...mails]; sorted.sort((a, b) => { for (const cond of sortState.conditions) { @@ -111,7 +111,7 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) { setOpen(!open); }; - const activeCount = sortState.conditions.length; + const activeCount = sortState.conditions?.length ?? 0; const addCondition = () => { onSortChange({ diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index 7153fd5..70e7637 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -70,6 +70,7 @@ export function ContactsListPage() { const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false); const [selectedContactIds, setSelectedContactIds] = useState>(new Set()); const openWindow = useWindowStore((s) => s.openWindow); + const closeWindow = useWindowStore((s) => s.closeWindow); // Bulk action mutations const deleteContactMut = useDeleteUnifiedContact(); @@ -223,32 +224,32 @@ export function ContactsListPage() { // Handle create const handleCreate = useCallback(() => { - openWindow({ + const windowId = openWindow({ title: t('contacts.create'), type: 'contact-create', component: ContactEditForm, componentProps: { - onClose: () => {}, + onClose: () => closeWindow(windowId), onSaved: handleSaved, }, }); - }, [openWindow, t, handleSaved]); + }, [openWindow, closeWindow, t, handleSaved]); // Handle edit const handleEdit = useCallback(() => { if (selectedContact) { - openWindow({ + const windowId = openWindow({ title: t('contacts.edit'), type: 'contact-edit', component: ContactEditForm, componentProps: { contact: selectedContact, - onClose: () => {}, + onClose: () => closeWindow(windowId), onSaved: handleSaved, }, }); } - }, [selectedContact, openWindow, t, handleSaved]); + }, [selectedContact, openWindow, closeWindow, t, handleSaved]); // Handle delete (from detail) const handleDeleted = useCallback(() => { @@ -747,6 +748,7 @@ export function ContactsListPage() { loading={loadingDetail} onEdit={handleEdit} onDeleted={handleDeleted} + dataTestId="contact-detail-mobile" />
    )} diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index e5a245c..15b4ab9 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -99,10 +99,10 @@ export function MailPage() { // Apply filter + sort to mails before rendering const processedMails = useMemo(() => { let result = mails; - if (mailFilterState.conditions.length > 0) { + if ((mailFilterState.conditions || []).length > 0) { result = applyMailFilters(result, mailFilterState); } - if (mailSortState.conditions.length > 0) { + if ((mailSortState.conditions || []).length > 0) { result = applyMailSorting(result, mailSortState); } return result; @@ -110,7 +110,7 @@ export function MailPage() { // Apply grouping after filter+sort const groupedMails = useMemo(() => { - if (mailGroupState.conditions.length === 0) return null; + if ((mailGroupState.conditions || []).length === 0) return null; return applyMailGrouping(processedMails, mailGroupState); }, [processedMails, mailGroupState]); @@ -153,7 +153,7 @@ export function MailPage() { // Load folders for ALL accounts const loadAllFolders = useCallback(async () => { - if (accounts.length === 0) return; + if ((accounts?.length ?? 0) === 0) return; setLoadingFolders(true); try { const allFolders: MailFolder[] = []; @@ -175,7 +175,7 @@ export function MailPage() { // Auto-select first folder when folders are loaded useEffect(() => { - if (folders.length > 0 && !selectedFolderId) { + if ((folders?.length ?? 0) > 0 && !selectedFolderId) { setSelectedFolderId(folders[0].id); setSelectedAccountId(folders[0].account_id); } @@ -189,21 +189,25 @@ export function MailPage() { try { if (searchQuery.trim()) { const result = await searchMails(searchQuery); + const resultMails = result.mails ?? []; + const resultTotal = result.total ?? 0; // Only update if still on the same folder if (selectedFolderIdRef.current === currentFolderId) { - setMails(result.mails); - setMailsTotal(result.total); + setMails(resultMails); + setMailsTotal(resultTotal); } } else { // When grouping is active, load all mails at once (no pagination) - const isGrouping = mailGroupState.conditions.length > 0; + const isGrouping = (mailGroupState.conditions || []).length > 0; const pageToLoad = isGrouping ? 1 : mailsPage; const result = await fetchMails(currentFolderId, pageToLoad, sortBy, sortOrder, isGrouping ? 10000 : undefined); + const resultMails = result.mails ?? []; + const resultTotal = result.total ?? 0; // Only update if still on the same folder if (selectedFolderIdRef.current === currentFolderId) { // Append for infinite scroll (page > 1), replace on page 1 or folder change or grouping - setMails(!isGrouping && mailsPage > 1 ? (prev) => [...prev, ...result.mails] : result.mails); - setMailsTotal(result.total); + setMails(!isGrouping && mailsPage > 1 ? (prev) => [...(prev ?? []), ...resultMails] : resultMails); + setMailsTotal(resultTotal); } } } catch (err) { @@ -851,7 +855,7 @@ export function MailPage() { ); } - if (accounts.length === 0) { + if ((accounts?.length ?? 0) === 0) { return (
    { - if (mails.length < mailsTotal) { + if ((mails?.length ?? 0) < mailsTotal) { setMailsPage((p) => p + 1); } }} - hasMore={mailGroupState.conditions.length === 0 && mails.length < mailsTotal} + hasMore={(mailGroupState.conditions || []).length === 0 && (mails?.length ?? 0) < mailsTotal} /> @@ -982,11 +986,11 @@ export function MailPage() { onToggleSelect={handleToggleSelect} onSelectAll={handleSelectAll} onLoadMore={() => { - if (mails.length < mailsTotal) { + if ((mails?.length ?? 0) < mailsTotal) { setMailsPage((p) => p + 1); } }} - hasMore={mailGroupState.conditions.length === 0 && mails.length < mailsTotal} + hasMore={(mailGroupState.conditions || []).length === 0 && (mails?.length ?? 0) < mailsTotal} />
    )} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index e82857a..4fe79d6 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -8,8 +8,9 @@ export function SettingsPage() { const { t } = useTranslation(); const manifests = usePluginStore(s => s.manifests); const pluginSettingsPages = useMemo( - () => manifests - .flatMap((m) => m.settings_pages) + () => (manifests || []) + .flatMap((m) => (Array.isArray(m.settings_pages) ? m.settings_pages : [])) + .filter((p): p is NonNullable => !!p && !!p.path) .sort((a, b) => a.order - b.order), [manifests] ); diff --git a/frontend/src/pages/SettingsPlugins.tsx b/frontend/src/pages/SettingsPlugins.tsx index 1e23156..0b3f2d3 100644 --- a/frontend/src/pages/SettingsPlugins.tsx +++ b/frontend/src/pages/SettingsPlugins.tsx @@ -180,7 +180,9 @@ export function SettingsPluginsPage() { const [confirmUninstall, setConfirmUninstall] = useState(null); const [confirmRemoveData, setConfirmRemoveData] = useState(false); - const plugins: Plugin[] = data ?? []; + const plugins: Plugin[] = Array.isArray(data) + ? data + : (data as any)?.plugins ?? []; const handleInstall = async (plugin: Plugin) => { try { diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 2413d2d..aba2c63 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; export interface Tenant { id: string; @@ -34,39 +35,49 @@ export interface AuthState { } export const useAuthStore = create()( - (set) => ({ - user: null, - currentTenant: null, - isAuthenticated: false, - isLoading: false, - error: null, - setUser: (user) => - set({ - user, - isAuthenticated: !!user, - currentTenant: user?.tenants?.[0] ?? null, - }), - setTenant: (tenant) => set({ currentTenant: tenant }), - setAuthenticated: (authed) => set({ isAuthenticated: authed }), - setLoading: (loading) => set({ isLoading: loading }), - setError: (error) => set({ error }), - setPermissions: (perms, isSystemAdmin, fieldPerms) => - set((state) => ({ - user: state.user - ? { - ...state.user, - permissions: perms, - is_system_admin: isSystemAdmin, - field_permissions: fieldPerms, - } - : null, - })), - logout: () => - set({ - user: null, - currentTenant: null, - isAuthenticated: false, - error: null, - }), - }) + persist( + (set) => ({ + user: null, + currentTenant: null, + isAuthenticated: false, + isLoading: false, + error: null, + setUser: (user) => + set({ + user, + isAuthenticated: !!user, + currentTenant: user?.tenants?.[0] ?? null, + }), + setTenant: (tenant) => set({ currentTenant: tenant }), + setAuthenticated: (authed) => set({ isAuthenticated: authed }), + setLoading: (loading) => set({ isLoading: loading }), + setError: (error) => set({ error }), + setPermissions: (perms, isSystemAdmin, fieldPerms) => + set((state) => ({ + user: state.user + ? { + ...state.user, + permissions: perms, + is_system_admin: isSystemAdmin, + field_permissions: fieldPerms, + } + : null, + })), + logout: () => + set({ + user: null, + currentTenant: null, + isAuthenticated: false, + error: null, + }), + }), + { + name: 'auth-store', + partialize: (state) => ({ + user: state.user, + currentTenant: state.currentTenant, + isAuthenticated: state.isAuthenticated, + }), + } + ) );