From fd48169219caf56b6a2d509d3cfbe075512bd831 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 7 Apr 2026 21:55:41 +0200 Subject: [PATCH] test(client): expand frontend test suite to 69.1% coverage Add and extend tests across 32 files (+10 595 lines) covering Admin panels (AuditLog, Backup, DevNotifications, GitHub), Collab (Chat, Notes, Panel, Polls), Planner (DayDetailPanel, DayPlanSidebar), Settings (DisplaySettings, Integrations, MapSettings), Files (FileManager, FilesPage), Map, Layout (DemoBanner, InAppNotificationBell), shared pickers (CustomDateTimePicker, CustomTimePicker), Vacay holidays, pages (Dashboard, Login), unit stores (authStore, inAppNotificationStore), API (authUrl, client integration), and i18n. Also updates sonar-project.properties and MSW trip handlers to support the new cases. --- client/src/App.test.tsx | 2 +- .../components/Admin/AuditLogPanel.test.tsx | 223 +++ .../src/components/Admin/BackupPanel.test.tsx | 313 +++ .../Admin/DevNotificationsPanel.test.tsx | 160 ++ .../src/components/Admin/GitHubPanel.test.tsx | 336 ++++ .../components/Budget/BudgetPanel.test.tsx | 180 ++ .../src/components/Collab/CollabChat.test.tsx | 550 +++++- .../components/Collab/CollabNotes.test.tsx | 1092 ++++++++++- .../components/Collab/CollabPanel.test.tsx | 144 ++ .../components/Collab/CollabPolls.test.tsx | 274 +++ .../src/components/Files/FileManager.test.tsx | 584 ++++++ .../src/components/Layout/DemoBanner.test.tsx | 116 ++ .../Layout/InAppNotificationBell.test.tsx | 144 +- client/src/components/Map/MapView.test.tsx | 208 ++ .../Planner/DayDetailPanel.test.tsx | 849 +++++++++ .../Planner/DayPlanSidebar.test.tsx | 1686 +++++++++++++++++ .../Settings/DisplaySettingsTab.test.tsx | 124 +- .../Settings/IntegrationsTab.test.tsx | 331 ++++ .../Settings/MapSettingsTab.test.tsx | 187 ++ client/src/components/Vacay/holidays.test.ts | 135 ++ .../shared/CustomDateTimePicker.test.tsx | 179 ++ .../shared/CustomTimePicker.test.tsx | 208 ++ client/src/pages/DashboardPage.test.tsx | 429 ++++- client/src/pages/FilesPage.test.tsx | 211 +++ client/src/pages/LoginPage.test.tsx | 346 +++- client/tests/helpers/msw/handlers/trips.ts | 10 + client/tests/integration/api/client.test.ts | 682 ++++++- client/tests/unit/api/authUrl.test.ts | 222 +++ client/tests/unit/i18n/index.test.ts | 210 ++ client/tests/unit/stores/authStore.test.ts | 247 +++ .../stores/inAppNotificationStore.test.ts | 217 +++ sonar-project.properties | 11 +- 32 files changed, 10595 insertions(+), 15 deletions(-) create mode 100644 client/src/components/Admin/AuditLogPanel.test.tsx create mode 100644 client/src/components/Admin/BackupPanel.test.tsx create mode 100644 client/src/components/Admin/DevNotificationsPanel.test.tsx create mode 100644 client/src/components/Admin/GitHubPanel.test.tsx create mode 100644 client/src/components/Collab/CollabPanel.test.tsx create mode 100644 client/src/components/Collab/CollabPolls.test.tsx create mode 100644 client/src/components/Files/FileManager.test.tsx create mode 100644 client/src/components/Layout/DemoBanner.test.tsx create mode 100644 client/src/components/Map/MapView.test.tsx create mode 100644 client/src/components/Planner/DayDetailPanel.test.tsx create mode 100644 client/src/components/Planner/DayPlanSidebar.test.tsx create mode 100644 client/src/components/Settings/IntegrationsTab.test.tsx create mode 100644 client/src/components/Settings/MapSettingsTab.test.tsx create mode 100644 client/src/components/Vacay/holidays.test.ts create mode 100644 client/src/components/shared/CustomDateTimePicker.test.tsx create mode 100644 client/src/components/shared/CustomTimePicker.test.tsx create mode 100644 client/src/pages/FilesPage.test.tsx create mode 100644 client/tests/unit/api/authUrl.test.ts create mode 100644 client/tests/unit/i18n/index.test.ts diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 2aa68122..9062f793 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -194,7 +194,7 @@ describe('App — on-mount effects', () => { it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => { const loadUser = vi.fn().mockResolvedValue(undefined) useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }) - renderApp('/login') + renderApp('/dashboard') expect(loadUser).toHaveBeenCalled() }) diff --git a/client/src/components/Admin/AuditLogPanel.test.tsx b/client/src/components/Admin/AuditLogPanel.test.tsx new file mode 100644 index 00000000..4d076f0e --- /dev/null +++ b/client/src/components/Admin/AuditLogPanel.test.tsx @@ -0,0 +1,223 @@ +// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010 +import { render, screen, waitFor } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { resetAllStores } from '../../../tests/helpers/store'; +import AuditLogPanel from './AuditLogPanel'; + +const ENTRY_1 = { + id: 1, + created_at: '2025-06-01T10:30:00Z', + user_id: 5, + username: 'alice', + user_email: 'alice@example.com', + action: 'trip.create', + resource: '/trips/42', + details: { title: 'Test' }, + ip: '127.0.0.1', +}; + +const ENTRY_2 = { + id: 2, + created_at: '2025-06-02T11:00:00Z', + user_id: 6, + username: 'bob', + user_email: 'bob@example.com', + action: 'trip.delete', + resource: '/trips/43', + details: null, + ip: '10.0.0.1', +}; + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('AuditLogPanel', () => { + it('FE-ADMIN-AUDIT-001: loading state shown on mount', async () => { + server.use( + http.get('/api/admin/audit-log', async () => { + await new Promise(() => {}); // never resolves + return HttpResponse.json({ entries: [], total: 0 }); + }), + ); + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(document.querySelector('table')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => { + server.use( + http.get('/api/admin/audit-log', () => + HttpResponse.json({ entries: [], total: 0 }), + ), + ); + render(); + await screen.findByText('No audit entries yet.'); + expect(document.querySelector('table')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => { + server.use( + http.get('/api/admin/audit-log', () => + HttpResponse.json({ entries: [ENTRY_1], total: 1 }), + ), + ); + render(); + await screen.findByText('trip.create'); + expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('Action')).toBeInTheDocument(); + expect(screen.getByText('Resource')).toBeInTheDocument(); + expect(screen.getByText('IP')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('/trips/42')).toBeInTheDocument(); + expect(screen.getByText('127.0.0.1')).toBeInTheDocument(); + expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument(); + }); + + it('FE-ADMIN-AUDIT-004: userLabel fallback chain', async () => { + const entries = [ + { ...ENTRY_1, id: 10, username: 'alice', user_email: null, user_id: 5, action: 'a.username' }, + { ...ENTRY_1, id: 11, username: null, user_email: 'bob@example.com', user_id: 6, action: 'a.email' }, + { ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' }, + { ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' }, + ]; + server.use( + http.get('/api/admin/audit-log', () => + HttpResponse.json({ entries, total: 4 }), + ), + ); + render(); + await screen.findByText('a.username'); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('bob@example.com')).toBeInTheDocument(); + expect(screen.getByText('#7')).toBeInTheDocument(); + // '—' appears multiple times (null resource, null ip for some, null user) — just check it exists + expect(screen.getAllByText('—').length).toBeGreaterThan(0); + }); + + it('FE-ADMIN-AUDIT-005: dash shown for null resource, ip, and details', async () => { + const entry = { + ...ENTRY_1, + id: 20, + action: 'a.nulls', + resource: null, + ip: null, + details: null, + }; + const entryEmptyDetails = { + ...ENTRY_1, + id: 21, + action: 'a.emptyobj', + resource: '/ok', + ip: '1.2.3.4', + details: {}, + }; + server.use( + http.get('/api/admin/audit-log', () => + HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }), + ), + ); + render(); + await screen.findByText('a.nulls'); + // null resource, null ip, null details → three '—' for entry; empty obj details → another '—' + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThanOrEqual(4); + }); + + it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => { + server.use( + http.get('/api/admin/audit-log', () => + HttpResponse.json({ entries: [ENTRY_1], total: 50 }), + ), + ); + render(); + await screen.findByText('trip.create'); + expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument(); + }); + + it('FE-ADMIN-AUDIT-007: "Load more" appends entries', async () => { + let callCount = 0; + server.use( + http.get('/api/admin/audit-log', () => { + callCount++; + if (callCount === 1) { + return HttpResponse.json({ entries: [ENTRY_1], total: 2 }); + } + return HttpResponse.json({ entries: [ENTRY_2], total: 2 }); + }), + ); + const user = userEvent.setup(); + render(); + await screen.findByText('trip.create'); + const loadMoreBtn = screen.getByText('Load more'); + expect(loadMoreBtn).toBeInTheDocument(); + await user.click(loadMoreBtn); + await screen.findByText('trip.delete'); + expect(screen.getByText('trip.create')).toBeInTheDocument(); + expect(screen.queryByText('Load more')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => { + server.use( + http.get('/api/admin/audit-log', () => + HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }), + ), + ); + render(); + await screen.findByText('trip.create'); + expect(screen.queryByText('Load more')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-AUDIT-009: Refresh resets list to page 1', async () => { + const PAGE1_ENTRY = { ...ENTRY_1, id: 100, action: 'phase1.action' }; + const PAGE2_ENTRY = { ...ENTRY_2, id: 101, action: 'phase2.action' }; + const REFRESH_ENTRY = { ...ENTRY_2, id: 102, action: 'phase3.refresh' }; + let callCount = 0; + server.use( + http.get('/api/admin/audit-log', () => { + callCount++; + if (callCount === 1) { + return HttpResponse.json({ entries: [PAGE1_ENTRY], total: 2 }); + } + if (callCount === 2) { + return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 }); + } + return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 }); + }), + ); + const user = userEvent.setup(); + render(); + // Initial load: PAGE1_ENTRY visible, load more + await screen.findByText('phase1.action'); + const loadMoreBtn = screen.getByText('Load more'); + await user.click(loadMoreBtn); + await screen.findByText('phase2.action'); + // Now refresh + const refreshBtn = screen.getByText('Refresh'); + await user.click(refreshBtn); + // After refresh, only REFRESH_ENTRY should be visible + await screen.findByText('phase3.refresh'); + await waitFor(() => expect(screen.queryByText('phase1.action')).not.toBeInTheDocument()); + expect(screen.queryByText('phase2.action')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-AUDIT-010: Refresh button is disabled while loading', async () => { + server.use( + http.get('/api/admin/audit-log', async () => { + await new Promise(() => {}); // never resolves + return HttpResponse.json({ entries: [], total: 0 }); + }), + ); + render(); + const refreshBtn = screen.getByText('Refresh'); + expect(refreshBtn.closest('button')).toBeDisabled(); + }); +}); diff --git a/client/src/components/Admin/BackupPanel.test.tsx b/client/src/components/Admin/BackupPanel.test.tsx new file mode 100644 index 00000000..21011795 --- /dev/null +++ b/client/src/components/Admin/BackupPanel.test.tsx @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useSettingsStore } from '../../store/settingsStore' +import { server } from '../../../tests/helpers/msw/server' +import { http, HttpResponse } from 'msw' +import BackupPanel from './BackupPanel' +import { ToastContainer } from '../shared/Toast' + +const manualBackup = { + filename: 'backup-2025-01-15.zip', + created_at: '2025-01-15T10:00:00Z', + size: 2048000, +} +const autoBackup = { + filename: 'auto-backup-2025-02-01.zip', + created_at: '2025-02-01T02:00:00Z', + size: 1024000, +} + +function defaultBackupHandlers() { + return [ + http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })), + http.get('/api/backup/auto-settings', () => + HttpResponse.json({ + settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }, + timezone: 'UTC', + }), + ), + ] +} + +function getToggleButton() { + // The enable toggle is a