dak.c2s/docs/plans/2026-02-26-frontend-testing-implementation.md
CCS Admin 75ddadc1f5 docs: add frontend testing implementation plan (13 tasks, ~155 tests)
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>
2026-02-26 21:02:34 +00:00

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
  • 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:

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.