Detailed task-by-task plan for Vitest + MSW + Playwright test infrastructure covering hooks, services, page integration tests and E2E browser tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
46 KiB
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
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:
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:
"types": ["vite/client", "vitest/globals"]
Step 4: Add test scripts to package.json
Add to scripts in frontend/package.json:
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
Step 5: Verify install
cd /home/frontend/dak_c2s/frontend && npx vitest --version
Step 6: Commit
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:
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:
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:
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:
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
cd /home/frontend/dak_c2s/frontend && pnpm test
Expected: 0 tests found, no errors (just "No test files found").
Step 6: Commit
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:
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 (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<AuthProvider>
{children}
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'> & { initialRoute?: string },
) {
const { initialRoute, ...renderOptions } = options ?? {}
return render(ui, {
wrapper: ({ children }) => <Providers initialRoute={initialRoute}>{children}</Providers>,
...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:
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 (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<AuthProvider>
{children}
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'> & { initialRoute?: string },
) {
const { initialRoute, ...renderOptions } = options ?? {}
return render(ui, {
wrapper: ({ children }) => (
<Providers initialRoute={initialRoute}>{children}</Providers>
),
...renderOptions,
})
}
export function renderHookWithProviders<TResult>(
hook: () => TResult,
options?: { initialRoute?: string },
) {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[options?.initialRoute ?? '/']}>
{children}
</MemoryRouter>
</QueryClientProvider>
)
return { ...renderHook(hook, { wrapper }), queryClient }
}
Step 2: Verify build still works
cd /home/frontend/dak_c2s/frontend && pnpm build
Step 3: Commit
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:
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:
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:
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
cd /home/frontend/dak_c2s/frontend && pnpm test
Expected: All service tests pass (~25 tests).
Step 5: Commit
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:
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:
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<string, string> = {}
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
cd /home/frontend/dak_c2s/frontend && pnpm test
Step 4: Commit
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:
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:
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:
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:
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:
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
requireAdminblocks non-admin usersrequireAdminallows 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:
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
cd /home/frontend/dak_c2s/frontend
pnpm add -D @playwright/test
npx playwright install chromium
Step 2: Create playwright.config.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:
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:
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:
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:
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
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
cd /home/frontend/dak_c2s/frontend && pnpm test
Expected: All ~135 unit/integration tests pass.
Step 2: Run coverage report
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
cd /home/frontend/dak_c2s/frontend && pnpm build
Step 4: Push and deploy
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:
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.