diff --git a/docs/plans/2026-02-26-frontend-testing-implementation.md b/docs/plans/2026-02-26-frontend-testing-implementation.md new file mode 100644 index 0000000..e1a1e5e --- /dev/null +++ b/docs/plans/2026-02-26-frontend-testing-implementation.md @@ -0,0 +1,1558 @@ +# 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.