# Frontend Testing Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement comprehensive frontend testing with Vitest + MSW for unit/integration tests and Playwright for E2E browser tests (~155 tests total). **Architecture:** Vitest with jsdom environment for hooks/services/pages, MSW to intercept HTTP requests at network level, `renderWithProviders()` helper for page tests (wraps QueryClient + Auth + Router), Playwright for critical user journeys against live app. **Tech Stack:** Vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, MSW 2.x, Playwright, React 19, TanStack Query v5, Axios --- ### Task 1: Install dependencies and configure Vitest **Files:** - Modify: `frontend/package.json` - Create: `frontend/vitest.config.ts` - Modify: `frontend/tsconfig.app.json` (add vitest types) **Step 1: Install test dependencies** ```bash cd /home/frontend/dak_c2s/frontend pnpm add -D vitest @vitest/coverage-v8 jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw ``` **Step 2: Create vitest.config.ts** Create `frontend/vitest.config.ts`: ```ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from 'path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, test: { globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.test.{ts,tsx}'], coverage: { provider: 'v8', include: ['src/hooks/**', 'src/services/**', 'src/pages/**', 'src/context/**', 'src/components/layout/**'], exclude: ['src/components/ui/**', 'src/test/**'], }, }, }) ``` **Step 3: Add vitest types to tsconfig.app.json** In `frontend/tsconfig.app.json`, change the `types` field: ```json "types": ["vite/client", "vitest/globals"] ``` **Step 4: Add test scripts to package.json** Add to `scripts` in `frontend/package.json`: ```json "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" ``` **Step 5: Verify install** ```bash cd /home/frontend/dak_c2s/frontend && npx vitest --version ``` **Step 6: Commit** ```bash cd /home/frontend/dak_c2s git add frontend/package.json frontend/pnpm-lock.yaml frontend/vitest.config.ts frontend/tsconfig.app.json git commit -m "feat: add Vitest testing infrastructure" ``` --- ### Task 2: Create MSW mock server, handlers, and mock data **Files:** - Create: `frontend/src/test/setup.ts` - Create: `frontend/src/test/mocks/server.ts` - Create: `frontend/src/test/mocks/data.ts` - Create: `frontend/src/test/mocks/handlers.ts` **Step 1: Create mock data** Create `frontend/src/test/mocks/data.ts`: ```ts import type { User, Case, CaseListResponse, DashboardResponse, DashboardKPIs, WeeklyDataPoint, Notification, NotificationList, ReportMeta, UserResponse, AuditLogEntry, DisclosureRequest, TokenResponse, } from '@/types' export const mockUser: User = { id: 1, username: 'admin', email: 'admin@test.de', first_name: 'Test', last_name: 'Admin', display_name: 'Test Admin', avatar_url: null, role: 'admin', mfa_enabled: false, is_active: true, last_login: '2026-02-26T10:00:00Z', created_at: '2026-01-01T00:00:00Z', } export const mockDakUser: User = { ...mockUser, id: 2, username: 'dak_user', email: 'dak@test.de', role: 'dak_mitarbeiter', first_name: 'DAK', last_name: 'Mitarbeiter', display_name: 'DAK Mitarbeiter', } export const mockTokenResponse: TokenResponse = { access_token: 'mock-access-token', refresh_token: 'mock-refresh-token', token_type: 'bearer', user: mockUser, } export const mockCase: Case = { id: 1, fall_id: '2026-06-onko-A123456789', crm_ticket_id: 'CRM-001', jahr: 2026, kw: 6, datum: '2026-02-09', anrede: 'Herr', vorname: 'Max', nachname: 'Mustermann', geburtsdatum: '1980-01-15', kvnr: 'A123456789', versicherung: 'DAK', icd: 'C50.9', fallgruppe: 'onko', strasse: 'Musterstr. 1', plz: '20095', ort: 'Hamburg', email: 'max@test.de', ansprechpartner: null, telefonnummer: null, mobiltelefon: null, unterlagen: true, unterlagen_verschickt: '2026-02-10', erhalten: true, unterlagen_erhalten: '2026-02-12', unterlagen_an_gutachter: '2026-02-13', gutachten: true, gutachter: 'Dr. Schmidt', gutachten_erstellt: '2026-02-20', gutachten_versendet: '2026-02-21', schweigepflicht: true, ablehnung: false, abbruch: false, abbruch_datum: null, gutachten_typ: 'Erstgutachten', therapieaenderung: 'Ja', ta_diagnosekorrektur: false, ta_unterversorgung: true, ta_uebertherapie: false, kurzbeschreibung: 'Onkologische Zweitmeinung', fragestellung: 'Therapieempfehlung', kommentar: null, sonstiges: null, abgerechnet: false, abrechnung_datum: null, import_source: 'excel_import', imported_at: '2026-02-09T08:00:00Z', updated_at: '2026-02-20T14:30:00Z', } export const mockCaseNoIcd: Case = { ...mockCase, id: 2, fall_id: '2026-06-kardio-B987654321', icd: null, fallgruppe: 'kardio', kvnr: 'B987654321', gutachten: false, gutachter: null, gutachten_erstellt: null, gutachten_versendet: null, } export const mockCaseListResponse: CaseListResponse = { items: [mockCase, mockCaseNoIcd], total: 2, page: 1, per_page: 50, } export const mockEmptyCaseList: CaseListResponse = { items: [], total: 0, page: 1, per_page: 50, } export const mockDashboardKPIs: DashboardKPIs = { total_cases: 150, pending_icd: 12, pending_coding: 8, total_gutachten: 95, fallgruppen: { onko: 80, kardio: 40, intensiv: 20, galle: 10, sd: 0 }, } export const mockWeeklyData: WeeklyDataPoint[] = [ { kw: 1, erstberatungen: 5, unterlagen: 3, ablehnungen: 1, keine_rm: 0, gutachten: 2 }, { kw: 2, erstberatungen: 8, unterlagen: 6, ablehnungen: 0, keine_rm: 1, gutachten: 4 }, ] export const mockDashboardResponse: DashboardResponse = { kpis: mockDashboardKPIs, weekly: mockWeeklyData, } export const mockNotification: Notification = { id: 1, notification_type: 'disclosure_approved', title: 'Freigabe genehmigt', message: 'Ihre Freigabe-Anfrage wurde genehmigt.', is_read: false, created_at: '2026-02-26T09:00:00Z', } export const mockNotificationRead: Notification = { ...mockNotification, id: 2, is_read: true, title: 'Alter Hinweis', notification_type: 'info', } export const mockNotificationList: NotificationList = { items: [mockNotification, mockNotificationRead], unread_count: 1, } export const mockReportMeta: ReportMeta = { id: 1, jahr: 2026, kw: 6, report_date: '2026-02-09', generated_at: '2026-02-26T10:00:00Z', } export const mockUserResponse: UserResponse = { id: 1, username: 'admin', email: 'admin@test.de', first_name: 'Test', last_name: 'Admin', display_name: 'Test Admin', avatar_url: null, role: 'admin', is_active: true, mfa_enabled: false, last_login: '2026-02-26T10:00:00Z', created_at: '2026-01-01T00:00:00Z', } export const mockAuditLogEntry: AuditLogEntry = { id: 1, user_id: 1, action: 'case.update', entity_type: 'case', entity_id: '1', old_values: { icd: null }, new_values: { icd: 'C50.9' }, ip_address: '10.10.181.104', created_at: '2026-02-26T10:00:00Z', } export const mockDisclosureRequest: DisclosureRequest = { id: 1, case_id: 1, requester_id: 2, requester_username: 'dak_user', fall_id: '2026-06-onko-A123456789', reason: 'KVNR-Fehler, Identifikation nötig', status: 'pending', reviewed_by: null, reviewed_at: null, expires_at: null, created_at: '2026-02-26T08:00:00Z', } export const mockDisclosureApproved: DisclosureRequest = { ...mockDisclosureRequest, id: 2, status: 'approved', reviewed_by: 1, reviewed_at: '2026-02-26T09:00:00Z', expires_at: '2026-02-27T09:00:00Z', } ``` **Step 2: Create MSW handlers** Create `frontend/src/test/mocks/handlers.ts`: ```ts import { http, HttpResponse } from 'msw' import { mockDashboardResponse, mockCaseListResponse, mockCase, mockNotificationList, mockReportMeta, mockUserResponse, mockAuditLogEntry, mockDisclosureRequest, mockUser, mockTokenResponse, } from './data' export const handlers = [ // Auth http.post('/api/auth/login', () => HttpResponse.json(mockTokenResponse)), http.post('/api/auth/register', () => HttpResponse.json({ user: mockUser })), http.post('/api/auth/logout', () => new HttpResponse(null, { status: 204 })), http.post('/api/auth/refresh', () => HttpResponse.json({ access_token: 'new-token' })), http.get('/api/auth/me', () => HttpResponse.json(mockUser)), http.put('/api/auth/profile', () => HttpResponse.json(mockUser)), http.post('/api/auth/avatar', () => HttpResponse.json(mockUser)), http.delete('/api/auth/avatar', () => HttpResponse.json(mockUser)), http.post('/api/auth/change-password', () => new HttpResponse(null, { status: 204 })), http.post('/api/auth/mfa/setup', () => HttpResponse.json({ secret: 'ABCDEF', qr_uri: 'otpauth://totp/test' })), http.post('/api/auth/mfa/verify', () => new HttpResponse(null, { status: 204 })), http.delete('/api/auth/mfa', () => new HttpResponse(null, { status: 204 })), // Dashboard http.get('/api/reports/dashboard', () => HttpResponse.json(mockDashboardResponse)), // Cases http.get('/api/cases/', () => HttpResponse.json(mockCaseListResponse)), http.get('/api/cases/pending-icd', () => HttpResponse.json(mockCaseListResponse)), http.put('/api/cases/:id', () => HttpResponse.json(mockCase)), http.put('/api/cases/:id/kvnr', () => HttpResponse.json(mockCase)), http.put('/api/cases/:id/icd', () => HttpResponse.json(mockCase)), // Notifications http.get('/api/notifications', () => HttpResponse.json(mockNotificationList)), http.put('/api/notifications/:id/read', () => new HttpResponse(null, { status: 204 })), http.put('/api/notifications/read-all', () => HttpResponse.json({ marked_read: 1 })), // Reports http.get('/api/reports/list', () => HttpResponse.json({ items: [mockReportMeta], total: 1 })), http.post('/api/reports/generate', () => HttpResponse.json(mockReportMeta)), http.delete('/api/reports/delete', () => new HttpResponse(null, { status: 204 })), // Admin Users http.get('/api/admin/users', () => HttpResponse.json([mockUserResponse])), http.post('/api/admin/users', () => HttpResponse.json(mockUserResponse)), http.put('/api/admin/users/:id', () => HttpResponse.json(mockUserResponse)), // Audit Log http.get('/api/admin/audit-log', () => HttpResponse.json([mockAuditLogEntry])), // Disclosure Requests http.get('/api/admin/disclosure-requests', () => HttpResponse.json([mockDisclosureRequest])), http.post('/api/cases/:id/disclosure-request', () => HttpResponse.json(mockDisclosureRequest)), http.put('/api/admin/disclosure-requests/:id', () => HttpResponse.json({ ...mockDisclosureRequest, status: 'approved' })), http.get('/api/admin/disclosure-requests/count', () => HttpResponse.json({ pending_count: 1 })), ] ``` **Step 3: Create MSW server** Create `frontend/src/test/mocks/server.ts`: ```ts import { setupServer } from 'msw/node' import { handlers } from './handlers' export const server = setupServer(...handlers) ``` **Step 4: Create test setup** Create `frontend/src/test/setup.ts`: ```ts import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' import { afterAll, afterEach, beforeAll } from 'vitest' import { server } from './mocks/server' beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => { cleanup() server.resetHandlers() }) afterAll(() => server.close()) ``` **Step 5: Run vitest to verify setup** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test ``` Expected: 0 tests found, no errors (just "No test files found"). **Step 6: Commit** ```bash cd /home/frontend/dak_c2s git add frontend/src/test/ git commit -m "feat: add MSW mock server, handlers, and test data" ``` --- ### Task 3: Create test utilities **Files:** - Create: `frontend/src/test/utils.tsx` **Step 1: Create renderWithProviders helper** Create `frontend/src/test/utils.tsx`: ```tsx import { render, type RenderOptions } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { AuthProvider } from '@/context/AuthContext' import type { ReactElement, ReactNode } from 'react' function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, }, mutations: { retry: false, }, }, }) } interface ProvidersProps { children: ReactNode initialRoute?: string } function Providers({ children, initialRoute = '/' }: ProvidersProps) { const queryClient = createTestQueryClient() return ( {children} ) } export function renderWithProviders( ui: ReactElement, options?: Omit & { initialRoute?: string }, ) { const { initialRoute, ...renderOptions } = options ?? {} return render(ui, { wrapper: ({ children }) => {children}, ...renderOptions, }) } export function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false }, }, }) } export { createTestQueryClient as createQueryClient } ``` Wait — there's a duplicate `createTestQueryClient`. Let me fix: Create `frontend/src/test/utils.tsx`: ```tsx import { render, type RenderOptions } from '@testing-library/react' import { renderHook, type RenderHookOptions } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { AuthProvider } from '@/context/AuthContext' import type { ReactElement, ReactNode } from 'react' export function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, }, mutations: { retry: false, }, }, }) } interface ProvidersProps { children: ReactNode initialRoute?: string } function Providers({ children, initialRoute = '/' }: ProvidersProps) { const queryClient = createTestQueryClient() return ( {children} ) } export function renderWithProviders( ui: ReactElement, options?: Omit & { initialRoute?: string }, ) { const { initialRoute, ...renderOptions } = options ?? {} return render(ui, { wrapper: ({ children }) => ( {children} ), ...renderOptions, }) } export function renderHookWithProviders( hook: () => TResult, options?: { initialRoute?: string }, ) { const queryClient = createTestQueryClient() const wrapper = ({ children }: { children: ReactNode }) => ( {children} ) return { ...renderHook(hook, { wrapper }), queryClient } } ``` **Step 2: Verify build still works** ```bash cd /home/frontend/dak_c2s/frontend && pnpm build ``` **Step 3: Commit** ```bash cd /home/frontend/dak_c2s git add frontend/src/test/utils.tsx git commit -m "feat: add test utilities (renderWithProviders, renderHookWithProviders)" ``` --- ### Task 4: Service tests — api.ts, authService.ts, disclosureService.ts **Files:** - Create: `frontend/src/services/__tests__/api.test.ts` - Create: `frontend/src/services/__tests__/authService.test.ts` - Create: `frontend/src/services/__tests__/disclosureService.test.ts` **Step 1: Write api.ts tests** Create `frontend/src/services/__tests__/api.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '@/test/mocks/server' import api from '@/services/api' describe('api service', () => { beforeEach(() => { localStorage.clear() }) it('attaches Authorization header when access_token exists', async () => { localStorage.setItem('access_token', 'test-token') let capturedAuth = '' server.use( http.get('/api/test', ({ request }) => { capturedAuth = request.headers.get('Authorization') ?? '' return HttpResponse.json({ ok: true }) }), ) await api.get('/test') expect(capturedAuth).toBe('Bearer test-token') }) it('does not attach Authorization header when no token', async () => { let capturedAuth: string | null = null server.use( http.get('/api/test', ({ request }) => { capturedAuth = request.headers.get('Authorization') return HttpResponse.json({ ok: true }) }), ) await api.get('/test') expect(capturedAuth).toBeNull() }) it('refreshes token on 401 and retries request', async () => { localStorage.setItem('access_token', 'expired-token') localStorage.setItem('refresh_token', 'valid-refresh') let callCount = 0 server.use( http.get('/api/test', () => { callCount++ if (callCount === 1) return new HttpResponse(null, { status: 401 }) return HttpResponse.json({ ok: true }) }), http.post('/api/auth/refresh', () => HttpResponse.json({ access_token: 'new-token' }), ), ) const res = await api.get('/test') expect(res.data).toEqual({ ok: true }) expect(localStorage.getItem('access_token')).toBe('new-token') }) it('redirects to /login when refresh fails', async () => { localStorage.setItem('access_token', 'expired-token') localStorage.setItem('refresh_token', 'invalid-refresh') const originalLocation = window.location.href // Mock window.location.href Object.defineProperty(window, 'location', { value: { ...window.location, href: originalLocation }, writable: true, }) server.use( http.get('/api/test', () => new HttpResponse(null, { status: 401 })), http.post('/api/auth/refresh', () => new HttpResponse(null, { status: 401 })), ) await expect(api.get('/test')).rejects.toThrow() expect(localStorage.getItem('access_token')).toBeNull() expect(localStorage.getItem('refresh_token')).toBeNull() }) it('passes through non-401 errors', async () => { server.use( http.get('/api/test', () => new HttpResponse(null, { status: 500 })), ) await expect(api.get('/test')).rejects.toThrow() }) it('does not retry 401 twice', async () => { localStorage.setItem('access_token', 'token') localStorage.setItem('refresh_token', 'refresh') let callCount = 0 server.use( http.get('/api/test', () => { callCount++ return new HttpResponse(null, { status: 401 }) }), http.post('/api/auth/refresh', () => HttpResponse.json({ access_token: 'new-token' }), ), ) await expect(api.get('/test')).rejects.toThrow() expect(callCount).toBe(2) // original + 1 retry }) }) ``` **Step 2: Write authService.ts tests** Create `frontend/src/services/__tests__/authService.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '@/test/mocks/server' import * as authService from '@/services/authService' import { mockUser, mockTokenResponse } from '@/test/mocks/data' describe('authService', () => { beforeEach(() => { localStorage.clear() }) describe('login', () => { it('stores tokens and returns response', async () => { const result = await authService.login({ email: 'admin@test.de', password: 'pass' }) expect(result.access_token).toBe('mock-access-token') expect(result.user.username).toBe('admin') expect(localStorage.getItem('access_token')).toBe('mock-access-token') expect(localStorage.getItem('refresh_token')).toBe('mock-refresh-token') }) it('throws on invalid credentials', async () => { server.use( http.post('/api/auth/login', () => HttpResponse.json({ detail: 'Invalid credentials' }, { status: 401 })), ) await expect(authService.login({ email: 'bad@test.de', password: 'wrong' })).rejects.toThrow() }) }) describe('register', () => { it('returns user', async () => { const result = await authService.register({ username: 'new', email: 'new@test.de', password: 'pass' }) expect(result.user.username).toBe('admin') }) }) describe('logout', () => { it('clears tokens from localStorage', async () => { localStorage.setItem('access_token', 'token') localStorage.setItem('refresh_token', 'refresh') await authService.logout() expect(localStorage.getItem('access_token')).toBeNull() expect(localStorage.getItem('refresh_token')).toBeNull() }) it('clears tokens even if API call fails', async () => { localStorage.setItem('access_token', 'token') localStorage.setItem('refresh_token', 'refresh') server.use( http.post('/api/auth/logout', () => new HttpResponse(null, { status: 500 })), ) await authService.logout() expect(localStorage.getItem('access_token')).toBeNull() }) }) describe('getMe', () => { it('returns user profile', async () => { const user = await authService.getMe() expect(user.id).toBe(1) expect(user.email).toBe('admin@test.de') }) }) describe('updateProfile', () => { it('returns updated user', async () => { const user = await authService.updateProfile({ first_name: 'Updated' }) expect(user.id).toBe(1) }) }) describe('uploadAvatar', () => { it('sends FormData and returns user', async () => { const file = new File(['data'], 'avatar.png', { type: 'image/png' }) const user = await authService.uploadAvatar(file) expect(user.id).toBe(1) }) }) describe('deleteAvatar', () => { it('returns user', async () => { const user = await authService.deleteAvatar() expect(user.id).toBe(1) }) }) describe('changePassword', () => { it('succeeds without error', async () => { await expect( authService.changePassword({ old_password: 'old', new_password: 'new' }), ).resolves.not.toThrow() }) it('throws on wrong old password', async () => { server.use( http.post('/api/auth/change-password', () => HttpResponse.json({ detail: 'Wrong password' }, { status: 400 }), ), ) await expect( authService.changePassword({ old_password: 'wrong', new_password: 'new' }), ).rejects.toThrow() }) }) describe('MFA', () => { it('setupMFA returns secret and qr_uri', async () => { const result = await authService.setupMFA() expect(result.secret).toBe('ABCDEF') expect(result.qr_uri).toContain('otpauth://') }) it('verifyMFA succeeds', async () => { await expect( authService.verifyMFA({ secret: 'ABCDEF', code: '123456' }), ).resolves.not.toThrow() }) it('disableMFA succeeds', async () => { await expect(authService.disableMFA('password')).resolves.not.toThrow() }) }) }) ``` **Step 3: Write disclosureService.ts tests** Create `frontend/src/services/__tests__/disclosureService.test.ts`: ```ts import { describe, it, expect } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '@/test/mocks/server' import * as disclosureService from '@/services/disclosureService' import { mockDisclosureRequest } from '@/test/mocks/data' describe('disclosureService', () => { describe('requestDisclosure', () => { it('sends request and returns disclosure', async () => { const result = await disclosureService.requestDisclosure(1, 'KVNR-Fehler') expect(result.id).toBe(1) expect(result.status).toBe('pending') }) it('throws on error', async () => { server.use( http.post('/api/cases/:id/disclosure-request', () => HttpResponse.json({ detail: 'Already requested' }, { status: 400 }), ), ) await expect(disclosureService.requestDisclosure(1, 'reason')).rejects.toThrow() }) }) describe('getDisclosureRequests', () => { it('returns list of disclosures', async () => { const result = await disclosureService.getDisclosureRequests('pending') expect(result).toHaveLength(1) expect(result[0].status).toBe('pending') }) it('passes status parameter', async () => { let capturedUrl = '' server.use( http.get('/api/admin/disclosure-requests', ({ request }) => { capturedUrl = request.url return HttpResponse.json([]) }), ) await disclosureService.getDisclosureRequests('approved') expect(capturedUrl).toContain('status=approved') }) it('works without status filter', async () => { const result = await disclosureService.getDisclosureRequests() expect(Array.isArray(result)).toBe(true) }) }) describe('getDisclosureCount', () => { it('returns pending count', async () => { const count = await disclosureService.getDisclosureCount() expect(count).toBe(1) }) }) describe('reviewDisclosure', () => { it('approves disclosure', async () => { const result = await disclosureService.reviewDisclosure(1, 'approved') expect(result.status).toBe('approved') }) it('rejects disclosure', async () => { server.use( http.put('/api/admin/disclosure-requests/:id', () => HttpResponse.json({ ...mockDisclosureRequest, status: 'rejected' }), ), ) const result = await disclosureService.reviewDisclosure(1, 'rejected') expect(result.status).toBe('rejected') }) }) }) ``` **Step 4: Run tests** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test ``` Expected: All service tests pass (~25 tests). **Step 5: Commit** ```bash cd /home/frontend/dak_c2s git add frontend/src/services/__tests__/ git commit -m "test: add service tests (api, authService, disclosureService)" ``` --- ### Task 5: Hook tests — useDashboard, useCases **Files:** - Create: `frontend/src/hooks/__tests__/useDashboard.test.ts` - Create: `frontend/src/hooks/__tests__/useCases.test.ts` **Step 1: Write useDashboard tests** Create `frontend/src/hooks/__tests__/useDashboard.test.ts`: ```ts import { describe, it, expect } from 'vitest' import { waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { server } from '@/test/mocks/server' import { renderHookWithProviders } from '@/test/utils' import { useDashboard } from '@/hooks/useDashboard' import { mockDashboardResponse } from '@/test/mocks/data' describe('useDashboard', () => { it('fetches dashboard data for given year', async () => { const { result } = renderHookWithProviders(() => useDashboard(2026)) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toEqual(mockDashboardResponse) }) it('includes year in query key (refetches on change)', async () => { let capturedJahr: string | null = null server.use( http.get('/api/reports/dashboard', ({ request }) => { const url = new URL(request.url) capturedJahr = url.searchParams.get('jahr') return HttpResponse.json(mockDashboardResponse) }), ) const { result } = renderHookWithProviders(() => useDashboard(2025)) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(capturedJahr).toBe('2025') }) it('handles server error', async () => { server.use( http.get('/api/reports/dashboard', () => new HttpResponse(null, { status: 500 })), ) const { result } = renderHookWithProviders(() => useDashboard(2026)) await waitFor(() => expect(result.current.isError).toBe(true)) }) }) ``` **Step 2: Write useCases tests** Create `frontend/src/hooks/__tests__/useCases.test.ts`: ```ts import { describe, it, expect } from 'vitest' import { waitFor, act } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { server } from '@/test/mocks/server' import { renderHookWithProviders } from '@/test/utils' import { useCases, usePendingIcdCases, useCaseUpdate, useKvnrUpdate, useIcdUpdate } from '@/hooks/useCases' import { mockCaseListResponse, mockCase, mockEmptyCaseList } from '@/test/mocks/data' describe('useCases', () => { it('fetches cases with filters', async () => { const { result } = renderHookWithProviders(() => useCases({ page: 1, per_page: 50 }), ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data?.items).toHaveLength(2) expect(result.current.data?.total).toBe(2) }) it('passes filter params to API', async () => { let capturedParams: Record = {} server.use( http.get('/api/cases/', ({ request }) => { const url = new URL(request.url) url.searchParams.forEach((v, k) => { capturedParams[k] = v }) return HttpResponse.json(mockCaseListResponse) }), ) const { result } = renderHookWithProviders(() => useCases({ page: 1, per_page: 50, search: 'test', jahr: 2026, fallgruppe: 'onko', has_icd: 'true' }), ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(capturedParams.search).toBe('test') expect(capturedParams.jahr).toBe('2026') expect(capturedParams.fallgruppe).toBe('onko') expect(capturedParams.has_icd).toBe('true') }) it('respects enabled: false', async () => { const { result } = renderHookWithProviders(() => useCases({ page: 1, per_page: 50 }, { enabled: false }), ) expect(result.current.fetchStatus).toBe('idle') }) it('handles empty response', async () => { server.use( http.get('/api/cases/', () => HttpResponse.json(mockEmptyCaseList)), ) const { result } = renderHookWithProviders(() => useCases({ page: 1, per_page: 50 }), ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data?.items).toHaveLength(0) }) it('handles server error', async () => { server.use( http.get('/api/cases/', () => new HttpResponse(null, { status: 500 })), ) const { result } = renderHookWithProviders(() => useCases({ page: 1, per_page: 50 }), ) await waitFor(() => expect(result.current.isError).toBe(true)) }) }) describe('usePendingIcdCases', () => { it('fetches pending ICD cases', async () => { const { result } = renderHookWithProviders(() => usePendingIcdCases(1, 50), ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data?.items).toHaveLength(2) }) it('respects enabled: false', async () => { const { result } = renderHookWithProviders(() => usePendingIcdCases(1, 50, { enabled: false }), ) expect(result.current.fetchStatus).toBe('idle') }) }) describe('useCaseUpdate', () => { it('updates case and invalidates cache', async () => { const { result, queryClient } = renderHookWithProviders(() => useCaseUpdate()) let updatedCase await act(async () => { updatedCase = await result.current.mutateAsync({ id: 1, data: { kommentar: 'Test' } }) }) expect(updatedCase).toBeDefined() expect(updatedCase!.id).toBe(1) }) it('handles error', async () => { server.use( http.put('/api/cases/:id', () => HttpResponse.json({ detail: 'Forbidden' }, { status: 403 })), ) const { result } = renderHookWithProviders(() => useCaseUpdate()) await expect( act(() => result.current.mutateAsync({ id: 1, data: {} })), ).rejects.toThrow() }) }) describe('useKvnrUpdate', () => { it('updates KVNR', async () => { const { result } = renderHookWithProviders(() => useKvnrUpdate()) let updatedCase await act(async () => { updatedCase = await result.current.mutateAsync({ id: 1, kvnr: 'A123456789' }) }) expect(updatedCase!.id).toBe(1) }) it('handles null KVNR', async () => { const { result } = renderHookWithProviders(() => useKvnrUpdate()) await act(async () => { await result.current.mutateAsync({ id: 1, kvnr: null }) }) expect(result.current.isSuccess).toBe(true) }) }) describe('useIcdUpdate', () => { it('updates ICD and invalidates cases + dashboard', async () => { const { result } = renderHookWithProviders(() => useIcdUpdate()) await act(async () => { await result.current.mutateAsync({ id: 1, icd: 'C50.9' }) }) expect(result.current.isSuccess).toBe(true) }) it('handles error', async () => { server.use( http.put('/api/cases/:id/icd', () => HttpResponse.json({ detail: 'Invalid ICD' }, { status: 422 })), ) const { result } = renderHookWithProviders(() => useIcdUpdate()) await expect( act(() => result.current.mutateAsync({ id: 1, icd: 'INVALID' })), ).rejects.toThrow() }) }) ``` **Step 3: Run tests** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test ``` **Step 4: Commit** ```bash cd /home/frontend/dak_c2s git add frontend/src/hooks/__tests__/ git commit -m "test: add hook tests for useDashboard and useCases" ``` --- ### Task 6: Hook tests — useDisclosures, useAuditLog, useUsers **Files:** - Create: `frontend/src/hooks/__tests__/useDisclosures.test.ts` - Create: `frontend/src/hooks/__tests__/useAuditLog.test.ts` - Create: `frontend/src/hooks/__tests__/useUsers.test.ts` The subagent should read the existing hook implementations and the mock data/handlers, then write comprehensive tests covering: **useDisclosures:** query with status filter, empty list, useReviewDisclosure approve/reject/error, cache invalidation of `['disclosures']` and `['notifications']` **useAuditLog:** query with various filters (skip, limit, user_id, action, date_from, date_to), empty response, error **useUsers:** useUsers query success/empty, useCreateUser success/validation error/cache invalidation, useUpdateUser success/error/cache invalidation Follow the same pattern as Task 5 — use `renderHookWithProviders`, `waitFor`, `act`, MSW server overrides for error cases. **Run tests, then commit:** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test git add frontend/src/hooks/__tests__/ && git commit -m "test: add hook tests for useDisclosures, useAuditLog, useUsers" ``` --- ### Task 7: Hook tests — useNotifications, useReports **Files:** - Create: `frontend/src/hooks/__tests__/useNotifications.test.ts` - Create: `frontend/src/hooks/__tests__/useReports.test.ts` **useNotifications:** query returns notifications + unreadCount, markAsRead mutation, markAllAsRead mutation, refresh invalidates cache, loading state, refetchInterval is configured (check via `vi.advanceTimersByTime` with `vi.useFakeTimers`) **useReports:** useReports query success/empty, useGenerateReport success/error/cache invalidation, useDeleteReports success/error/cache invalidation Follow same patterns. Run tests, then commit: ```bash cd /home/frontend/dak_c2s/frontend && pnpm test git add frontend/src/hooks/__tests__/ && git commit -m "test: add hook tests for useNotifications and useReports" ``` --- ### Task 8: Page integration tests — DashboardPage, DisclosuresPage, AdminAuditPage **Files:** - Create: `frontend/src/pages/__tests__/DashboardPage.test.tsx` - Create: `frontend/src/pages/__tests__/DisclosuresPage.test.tsx` - Create: `frontend/src/pages/__tests__/AdminAuditPage.test.tsx` These are simpler pages. Use `renderWithProviders` and `screen` from `@testing-library/react`. MSW handles API responses. **DashboardPage tests:** - Renders KPI cards with correct values (150, 12, 8, 95) - Shows year selector - Renders loading skeletons initially - Filters 0-value Fallgruppen from pie chart data - Handles error state **DisclosuresPage tests:** - Renders pending disclosure requests - Shows approve/reject buttons - Handles empty list message - Shows loading skeletons - Disables buttons during mutation (isPending) **AdminAuditPage tests:** - Renders audit log entries - Shows filter controls (user_id, action, date_from, date_to) - Handles empty list - Shows loading state NOTE: The MSW handlers from `handlers.ts` already mock the auth endpoint (`GET /api/auth/me`), which AuthProvider calls on mount. The mock returns `mockUser` (admin role). For pages that need `isAdmin`, this is already handled. For tests needing a non-admin user, override the handler in the test. **Run tests, then commit:** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test git add frontend/src/pages/__tests__/ && git commit -m "test: add page tests for Dashboard, Disclosures, AdminAudit" ``` --- ### Task 9: Page integration tests — AdminUsersPage, ReportsPage, LoginPage **Files:** - Create: `frontend/src/pages/__tests__/AdminUsersPage.test.tsx` - Create: `frontend/src/pages/__tests__/ReportsPage.test.tsx` - Create: `frontend/src/pages/__tests__/LoginPage.test.tsx` **AdminUsersPage tests:** - Renders user list - Create dialog opens and submits - Edit dialog opens and submits - Handles validation errors - Shows loading state **ReportsPage tests:** - Renders report list - Generate report form works - Delete with checkbox selection - Shows isPending states on buttons - Handles errors **LoginPage tests:** - Renders email/password fields - Shows validation errors for empty fields - Submits and redirects on success - Shows error message on failed login **Run tests, then commit:** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test git add frontend/src/pages/__tests__/ && git commit -m "test: add page tests for AdminUsers, Reports, Login" ``` --- ### Task 10: Page integration tests — CasesPage (most complex) **Files:** - Create: `frontend/src/pages/__tests__/CasesPage.test.tsx` This is the most complex page. Test: - Renders case list table with fall_id, datum, fallgruppe columns - Search input triggers debounced filter - Year/Fallgruppe/ICD select filters work - Pagination controls (prev/next, page display) - Clicking a row opens the detail Sheet - Detail sheet shows case fields (fall_id, CRM-Ticket, Datum, etc.) - Edit button enables inline editing - Save button calls mutation - ICD input + save works - pendingIcdOnly mode fetches from different endpoint - Empty list shows message - Admin sees Nachname/Vorname columns, non-admin does not For the Sheet/inline-edit tests, use `@testing-library/user-event` for realistic user interactions (click, type, tab). **Run tests, then commit:** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test git add frontend/src/pages/__tests__/ && git commit -m "test: add page tests for CasesPage (list + detail + inline edit)" ``` --- ### Task 11: Page integration tests — ProtectedRoute, remaining pages **Files:** - Create: `frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx` **ProtectedRoute tests:** - Redirects to /login when not authenticated (no token) - Shows children when authenticated - `requireAdmin` blocks non-admin users - `requireAdmin` allows admin users - Shows loading state while auth is loading Read the actual ProtectedRoute component first to understand its logic. Also add any remaining page tests if ImportPage or AccountPage have been skipped. If time allows, add basic render tests for those. **Run tests, then commit:** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test git add frontend/src/ && git commit -m "test: add ProtectedRoute tests and remaining page tests" ``` --- ### Task 12: Install Playwright and create E2E tests **Files:** - Modify: `frontend/package.json` - Create: `frontend/playwright.config.ts` - Create: `frontend/e2e/auth.spec.ts` - Create: `frontend/e2e/dashboard.spec.ts` - Create: `frontend/e2e/cases.spec.ts` - Create: `frontend/e2e/admin.spec.ts` **Step 1: Install Playwright** ```bash cd /home/frontend/dak_c2s/frontend pnpm add -D @playwright/test npx playwright install chromium ``` **Step 2: Create playwright.config.ts** ```ts import { defineConfig } from '@playwright/test' export default defineConfig({ testDir: './e2e', timeout: 30_000, retries: 1, use: { baseURL: 'http://localhost:5173', headless: true, screenshot: 'only-on-failure', }, webServer: { command: 'pnpm dev', port: 5173, reuseExistingServer: true, }, }) ``` **Step 3: Create E2E test files** Create `frontend/e2e/auth.spec.ts`: ```ts import { test, expect } from '@playwright/test' test.describe('Authentication', () => { test('login with valid credentials redirects to dashboard', async ({ page }) => { await page.goto('/login') await page.fill('input[type="email"]', 'admin@dak-portal.de') await page.fill('input[type="password"]', 'admin123') await page.click('button[type="submit"]') await expect(page).toHaveURL(/dashboard/) await expect(page.locator('h1')).toContainText('Dashboard') }) test('login with invalid credentials shows error', async ({ page }) => { await page.goto('/login') await page.fill('input[type="email"]', 'wrong@test.de') await page.fill('input[type="password"]', 'wrongpass') await page.click('button[type="submit"]') await expect(page.locator('[role="alert"], .text-red-600, .text-destructive')).toBeVisible() }) test('protected route redirects to login without auth', async ({ page }) => { await page.goto('/dashboard') await expect(page).toHaveURL(/login/) }) test('logout redirects to login', async ({ page }) => { // Login first await page.goto('/login') await page.fill('input[type="email"]', 'admin@dak-portal.de') await page.fill('input[type="password"]', 'admin123') await page.click('button[type="submit"]') await expect(page).toHaveURL(/dashboard/) // Logout via user menu await page.click('[data-testid="user-menu"], button:has(span.sr-only:has-text("Abmelden"))') // This may need adjustment based on actual DOM structure }) }) ``` Create `frontend/e2e/dashboard.spec.ts`: ```ts import { test, expect } from '@playwright/test' test.describe('Dashboard', () => { test.beforeEach(async ({ page }) => { await page.goto('/login') await page.fill('input[type="email"]', 'admin@dak-portal.de') await page.fill('input[type="password"]', 'admin123') await page.click('button[type="submit"]') await expect(page).toHaveURL(/dashboard/) }) test('shows KPI cards', async ({ page }) => { await expect(page.getByText('Fälle gesamt')).toBeVisible() await expect(page.getByText('Offene ICD')).toBeVisible() await expect(page.getByText('Offene Codierung')).toBeVisible() await expect(page.getByText('Gutachten gesamt')).toBeVisible() }) test('year selector changes data', async ({ page }) => { await page.click('button:has-text("2026")') await page.click('[role="option"]:has-text("2025")') // Data should reload (check network or wait for content change) await expect(page.getByText('Fälle gesamt')).toBeVisible() }) test('shows Fallgruppen chart', async ({ page }) => { await expect(page.getByText('Fallgruppen')).toBeVisible() }) }) ``` Create `frontend/e2e/cases.spec.ts`: ```ts import { test, expect } from '@playwright/test' test.describe('Cases', () => { test.beforeEach(async ({ page }) => { await page.goto('/login') await page.fill('input[type="email"]', 'admin@dak-portal.de') await page.fill('input[type="password"]', 'admin123') await page.click('button[type="submit"]') await expect(page).toHaveURL(/dashboard/) await page.goto('/cases') }) test('shows case list table', async ({ page }) => { await expect(page.getByText('Fälle')).toBeVisible() await expect(page.locator('table')).toBeVisible() await expect(page.getByText('Fall-ID')).toBeVisible() }) test('search filters cases', async ({ page }) => { await page.fill('input[placeholder*="Suche"]', 'onko') // Wait for debounce (300ms) + API response await page.waitForTimeout(500) await expect(page.locator('table')).toBeVisible() }) test('clicking a case opens detail sheet', async ({ page }) => { await page.locator('table tbody tr').first().click() await expect(page.locator('[role="dialog"], [data-state="open"]')).toBeVisible() }) }) ``` Create `frontend/e2e/admin.spec.ts`: ```ts import { test, expect } from '@playwright/test' test.describe('Admin Pages', () => { test.beforeEach(async ({ page }) => { await page.goto('/login') await page.fill('input[type="email"]', 'admin@dak-portal.de') await page.fill('input[type="password"]', 'admin123') await page.click('button[type="submit"]') await expect(page).toHaveURL(/dashboard/) }) test('admin users page loads', async ({ page }) => { await page.goto('/admin/users') await expect(page.getByText('Benutzer')).toBeVisible() }) test('audit log page loads', async ({ page }) => { await page.goto('/admin/audit') await expect(page.getByText('Audit-Log')).toBeVisible() }) test('disclosures page loads', async ({ page }) => { await page.goto('/admin/disclosures') await expect(page.getByText('Freigabe-Anfragen')).toBeVisible() }) }) ``` **Step 4: Add E2E script to package.json** Add to `scripts`: `"test:e2e": "playwright test"` **Step 5: Commit** ```bash cd /home/frontend/dak_c2s git add frontend/playwright.config.ts frontend/e2e/ frontend/package.json frontend/pnpm-lock.yaml git commit -m "test: add Playwright E2E tests (auth, dashboard, cases, admin)" ``` --- ### Task 13: Final verification, coverage, and deploy **Step 1: Run all unit/integration tests** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test ``` Expected: All ~135 unit/integration tests pass. **Step 2: Run coverage report** ```bash cd /home/frontend/dak_c2s/frontend && pnpm test:coverage ``` Review coverage output — hooks and services should have >90% coverage. **Step 3: Verify build still works** ```bash cd /home/frontend/dak_c2s/frontend && pnpm build ``` **Step 4: Push and deploy** ```bash cd /home/frontend/dak_c2s git push origin develop git checkout main && git pull origin main && git merge develop && git push origin main git checkout develop ``` Deploy to Hetzner 1: ```bash ssh hetzner1 "cd /opt/dak-portal && git pull origin main && cd frontend && pnpm install && pnpm build && cp -r dist/* /var/www/vhosts/complexcaresolutions.de/dak.complexcaresolutions.de/dist/" ``` Note: E2E tests (`pnpm test:e2e`) require a running dev server + backend and should be run manually on the dev machine, not during deployment.