mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 20:43:41 +00:00
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>
1558 lines
46 KiB
Markdown
1558 lines
46 KiB
Markdown
# 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 (
|
|
<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`:
|
|
|
|
```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**
|
|
|
|
```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<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**
|
|
|
|
```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.
|