diff --git a/frontend/src/pages/__tests__/CasesPage.test.tsx b/frontend/src/pages/__tests__/CasesPage.test.tsx
new file mode 100644
index 0000000..b49c6ce
--- /dev/null
+++ b/frontend/src/pages/__tests__/CasesPage.test.tsx
@@ -0,0 +1,266 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { http, HttpResponse } from 'msw'
+import { server } from '@/test/mocks/server'
+import { renderWithProviders } from '@/test/utils'
+import { CasesPage } from '@/pages/CasesPage'
+import { mockCase, mockCaseNoIcd, mockEmptyCaseList } from '@/test/mocks/data'
+
+describe('CasesPage', () => {
+ beforeEach(() => {
+ localStorage.setItem('access_token', 'test-token')
+ })
+
+ // ==================== List rendering ====================
+
+ it('renders the page heading', async () => {
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText('Fälle')).toBeInTheDocument()
+ })
+ })
+
+ it('renders case list table with fall_id, datum, and fallgruppe columns', async () => {
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ // Wait for the table to load with data
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Check table headers
+ expect(screen.getByText('Fall-ID')).toBeInTheDocument()
+ expect(screen.getByText('Datum')).toBeInTheDocument()
+ expect(screen.getByText('Fallgruppe')).toBeInTheDocument()
+ expect(screen.getByText('KVNR')).toBeInTheDocument()
+ expect(screen.getByText('ICD')).toBeInTheDocument()
+ // "Gutachten" appears both as table header and as a status badge, so use getAllByText
+ expect(screen.getAllByText('Gutachten').length).toBeGreaterThanOrEqual(1)
+ expect(screen.getByText('Status')).toBeInTheDocument()
+
+ // Admin-only columns should be visible (default mockUser is admin)
+ expect(screen.getByText('Nachname')).toBeInTheDocument()
+ expect(screen.getByText('Vorname')).toBeInTheDocument()
+ })
+
+ it('shows correct number of cases from mock data', async () => {
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ // Wait for data to load
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Both mock cases should be rendered
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ expect(screen.getByText(mockCaseNoIcd.fall_id!)).toBeInTheDocument()
+
+ // Total count shown in pagination area
+ expect(screen.getByText('2 Fälle insgesamt')).toBeInTheDocument()
+ })
+
+ it('renders case data correctly in table rows', async () => {
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Check first case data
+ expect(screen.getByText('Onkologie')).toBeInTheDocument()
+ expect(screen.getByText(mockCase.kvnr!)).toBeInTheDocument()
+ expect(screen.getByText(mockCase.icd!)).toBeInTheDocument()
+ expect(screen.getByText(mockCase.nachname!)).toBeInTheDocument()
+
+ // Second case should also be present
+ expect(screen.getByText(mockCaseNoIcd.fall_id!)).toBeInTheDocument()
+ expect(screen.getByText(mockCaseNoIcd.kvnr!)).toBeInTheDocument()
+ })
+
+ it('handles empty list and shows empty message', async () => {
+ server.use(
+ http.get('/api/cases/', () => {
+ return HttpResponse.json(mockEmptyCaseList)
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText('Keine Fälle gefunden.')).toBeInTheDocument()
+ })
+ })
+
+ it('shows loading state with skeleton placeholders', () => {
+ // Delay the API response to observe loading state
+ server.use(
+ http.get('/api/cases/', async () => {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return HttpResponse.json(mockEmptyCaseList)
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ // During loading, the heading is always visible
+ expect(screen.getByText('Fälle')).toBeInTheDocument()
+
+ // Table data should not be visible yet
+ expect(screen.queryByText('Fall-ID')).not.toBeInTheDocument()
+ })
+
+ // ==================== Filtering ====================
+
+ it('renders search input', async () => {
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Search input with admin placeholder
+ const searchInput = screen.getByPlaceholderText('Suche nach Name, Fall-ID, KVNR...')
+ expect(searchInput).toBeInTheDocument()
+ })
+
+ it('allows typing into the search input', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ const searchInput = screen.getByPlaceholderText('Suche nach Name, Fall-ID, KVNR...')
+ await user.type(searchInput, 'onko')
+ expect(searchInput).toHaveValue('onko')
+ })
+
+ it('renders year and fallgruppe filter selects', async () => {
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Year select trigger shows "Alle Jahre" by default
+ expect(screen.getByText('Alle Jahre')).toBeInTheDocument()
+
+ // Fallgruppe select trigger shows "Alle Fallgruppen" by default
+ expect(screen.getByText('Alle Fallgruppen')).toBeInTheDocument()
+
+ // ICD filter shows "Alle" by default
+ // There are multiple "Alle" texts, so we verify the ICD filter trigger exists
+ const alleTriggers = screen.getAllByText('Alle')
+ expect(alleTriggers.length).toBeGreaterThanOrEqual(1)
+ })
+
+ // ==================== Detail view ====================
+
+ it('opens detail sheet when clicking a table row', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ // Wait for data to load
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Click on the first case's fall_id cell (which is inside a table row)
+ await user.click(screen.getByText(mockCase.fall_id!))
+
+ // The Sheet (Radix Dialog) should open with case details
+ await waitFor(() => {
+ const dialog = screen.getByRole('dialog')
+ expect(dialog).toBeInTheDocument()
+ })
+
+ // SheetTitle shows the fall_id
+ const dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByText(`Fall ${mockCase.fall_id}`)).toBeInTheDocument()
+ })
+
+ it('shows case detail information in the sheet', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Click on the first case
+ await user.click(screen.getByText(mockCase.fall_id!))
+
+ // Wait for sheet to open
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ const dialog = screen.getByRole('dialog')
+
+ // SheetDescription shows fallgruppe and KW/year
+ expect(within(dialog).getByText(/Onkologie/)).toBeInTheDocument()
+
+ // Read-only metadata fields
+ expect(within(dialog).getByText('Fall-ID')).toBeInTheDocument()
+ expect(within(dialog).getByText('CRM-Ticket')).toBeInTheDocument()
+
+ // Editable section headers from fieldConfig
+ expect(within(dialog).getByText('Persönliche Daten')).toBeInTheDocument()
+ expect(within(dialog).getByText('Kontakt')).toBeInTheDocument()
+ expect(within(dialog).getByText('Falldetails')).toBeInTheDocument()
+
+ // ICD section
+ expect(within(dialog).getByText('ICD-Code')).toBeInTheDocument()
+
+ // Edit button should be present
+ expect(within(dialog).getByText('Bearbeiten')).toBeInTheDocument()
+ })
+
+ // ==================== Error handling ====================
+
+ it('shows empty state when API returns 500', async () => {
+ server.use(
+ http.get('/api/cases/', () => {
+ return new HttpResponse(null, { status: 500 })
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ // When the query fails, data is null, so the empty message is shown
+ await waitFor(() => {
+ expect(screen.getByText('Keine Fälle gefunden.')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Pagination ====================
+
+ it('shows pagination controls', async () => {
+ renderWithProviders(, { initialRoute: '/cases' })
+
+ await waitFor(() => {
+ expect(screen.getByText(mockCase.fall_id!)).toBeInTheDocument()
+ })
+
+ // Pagination text
+ expect(screen.getByText('Seite 1 von 1')).toBeInTheDocument()
+ expect(screen.getByText('2 Fälle insgesamt')).toBeInTheDocument()
+ })
+
+ // ==================== pendingIcdOnly mode ====================
+
+ it('renders ICD-Eingabe heading when pendingIcdOnly is true', async () => {
+ renderWithProviders(, { initialRoute: '/cases/pending-icd' })
+
+ await waitFor(() => {
+ expect(screen.getByText('ICD-Eingabe')).toBeInTheDocument()
+ })
+
+ // The filter bar should NOT be rendered in pendingIcdOnly mode
+ expect(screen.queryByPlaceholderText('Suche nach Name, Fall-ID, KVNR...')).not.toBeInTheDocument()
+ expect(screen.queryByText('Alle Fallgruppen')).not.toBeInTheDocument()
+ })
+})