diff --git a/frontend/src/pages/__tests__/AdminUsersPage.test.tsx b/frontend/src/pages/__tests__/AdminUsersPage.test.tsx
new file mode 100644
index 0000000..c592b62
--- /dev/null
+++ b/frontend/src/pages/__tests__/AdminUsersPage.test.tsx
@@ -0,0 +1,94 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { screen, waitFor } 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 { AdminUsersPage } from '@/pages/AdminUsersPage'
+
+describe('AdminUsersPage', () => {
+ beforeEach(() => {
+ localStorage.setItem('access_token', 'test-token')
+ })
+
+ it('renders user list with user data', async () => {
+ renderWithProviders(, { initialRoute: '/admin/users' })
+
+ await waitFor(() => {
+ // mockUserResponse has username 'admin_user', email 'admin@dak.de', role 'admin'
+ expect(screen.getByText('admin_user')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('admin@dak.de')).toBeInTheDocument()
+ expect(screen.getByText('Admin')).toBeInTheDocument()
+ // "Aktiv" appears both as table header and badge; check for at least 2
+ const aktivElements = screen.getAllByText('Aktiv')
+ expect(aktivElements.length).toBeGreaterThanOrEqual(2)
+ })
+
+ it('shows "Neuen Benutzer erstellen" button and opens dialog', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, { initialRoute: '/admin/users' })
+
+ await waitFor(() => {
+ expect(screen.getByText('Neuen Benutzer erstellen')).toBeInTheDocument()
+ })
+
+ // Click the create button to open the dialog
+ await user.click(screen.getByText('Neuen Benutzer erstellen'))
+
+ // Dialog should open with form fields
+ await waitFor(() => {
+ expect(screen.getByLabelText('Benutzername')).toBeInTheDocument()
+ })
+ expect(screen.getByLabelText('E-Mail')).toBeInTheDocument()
+ expect(screen.getByLabelText('Passwort')).toBeInTheDocument()
+ })
+
+ it('handles empty user list', async () => {
+ server.use(
+ http.get('/api/admin/users', () => {
+ return HttpResponse.json([])
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/admin/users' })
+
+ await waitFor(() => {
+ expect(screen.getByText('Keine Benutzer gefunden.')).toBeInTheDocument()
+ })
+ })
+
+ it('shows loading state', () => {
+ server.use(
+ http.get('/api/admin/users', async () => {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return HttpResponse.json([])
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/admin/users' })
+
+ // Heading is always visible
+ expect(screen.getByText('Benutzer')).toBeInTheDocument()
+
+ // During loading, user data should not be visible yet
+ expect(screen.queryByText('admin_user')).not.toBeInTheDocument()
+ expect(screen.queryByText('Keine Benutzer gefunden.')).not.toBeInTheDocument()
+ })
+
+ it('shows error state on API failure', async () => {
+ server.use(
+ http.get('/api/admin/users', () => {
+ return new HttpResponse(null, { status: 500 })
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/admin/users' })
+
+ // When the query fails, users defaults to [] so it should show empty state
+ await waitFor(() => {
+ expect(screen.getByText('Keine Benutzer gefunden.')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/src/pages/__tests__/LoginPage.test.tsx b/frontend/src/pages/__tests__/LoginPage.test.tsx
new file mode 100644
index 0000000..3faba8e
--- /dev/null
+++ b/frontend/src/pages/__tests__/LoginPage.test.tsx
@@ -0,0 +1,94 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { screen, waitFor } 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 { LoginPage } from '@/pages/LoginPage'
+
+// Mock window.matchMedia for useTheme hook
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+})
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ // LoginPage is for unauthenticated users — do NOT set access_token
+ localStorage.removeItem('access_token')
+ localStorage.removeItem('refresh_token')
+
+ // Override /api/auth/me to return 401 for unauthenticated state
+ server.use(
+ http.get('/api/auth/me', () => {
+ return new HttpResponse(null, { status: 401 })
+ }),
+ )
+ })
+
+ it('renders email and password input fields', () => {
+ renderWithProviders(, { initialRoute: '/login' })
+
+ expect(screen.getByLabelText('E-Mail')).toBeInTheDocument()
+ expect(screen.getByLabelText('Passwort')).toBeInTheDocument()
+ })
+
+ it('renders submit button', () => {
+ renderWithProviders(, { initialRoute: '/login' })
+
+ expect(screen.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument()
+ })
+
+ it('shows error message on failed login', async () => {
+ const user = userEvent.setup()
+
+ // Override login endpoint to return 401
+ server.use(
+ http.post('/api/auth/login', () => {
+ return HttpResponse.json(
+ { detail: 'Invalid credentials' },
+ { status: 401 },
+ )
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/login' })
+
+ // Fill in valid form data
+ await user.type(screen.getByLabelText('E-Mail'), 'wrong@dak.de')
+ await user.type(screen.getByLabelText('Passwort'), 'wrongpassword')
+
+ // Submit the form
+ await user.click(screen.getByRole('button', { name: 'Anmelden' }))
+
+ // Should show error message for 401
+ await waitFor(() => {
+ expect(screen.getByText('Ungueltige Anmeldedaten')).toBeInTheDocument()
+ })
+ })
+
+ it('shows validation errors for empty form submission', async () => {
+ const user = userEvent.setup()
+
+ renderWithProviders(, { initialRoute: '/login' })
+
+ // Click submit without filling in any fields
+ await user.click(screen.getByRole('button', { name: 'Anmelden' }))
+
+ // Zod validation errors should appear
+ await waitFor(() => {
+ expect(screen.getByText('Bitte geben Sie eine gueltige E-Mail-Adresse ein')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('Passwort ist erforderlich')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/__tests__/ReportsPage.test.tsx b/frontend/src/pages/__tests__/ReportsPage.test.tsx
new file mode 100644
index 0000000..b65d9bb
--- /dev/null
+++ b/frontend/src/pages/__tests__/ReportsPage.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import { http, HttpResponse } from 'msw'
+import { server } from '@/test/mocks/server'
+import { renderWithProviders } from '@/test/utils'
+import { ReportsPage } from '@/pages/ReportsPage'
+
+describe('ReportsPage', () => {
+ beforeEach(() => {
+ localStorage.setItem('access_token', 'test-token')
+ })
+
+ it('renders report list with report metadata', async () => {
+ renderWithProviders(, { initialRoute: '/reports' })
+
+ await waitFor(() => {
+ // mockReportMeta has jahr: 2026, kw: 6
+ expect(screen.getByText('2026')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('KW 6')).toBeInTheDocument()
+
+ // Total count should show in the header "Bisherige Berichte (1)"
+ expect(screen.getByText('Bisherige Berichte (1)')).toBeInTheDocument()
+ })
+
+ it('shows "Bericht generieren" button and form fields', async () => {
+ renderWithProviders(, { initialRoute: '/reports' })
+
+ await waitFor(() => {
+ // "Bericht generieren" appears as card title and button text; use button role
+ expect(screen.getByRole('button', { name: /Bericht generieren/ })).toBeInTheDocument()
+ })
+
+ // Admin user sees the generation form with Jahr and KW inputs
+ expect(screen.getByLabelText('Jahr')).toBeInTheDocument()
+ expect(screen.getByLabelText('Kalenderwoche')).toBeInTheDocument()
+ })
+
+ it('handles empty report list', async () => {
+ server.use(
+ http.get('/api/reports/list', () => {
+ return HttpResponse.json({ items: [], total: 0 })
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/reports' })
+
+ await waitFor(() => {
+ expect(screen.getByText('Keine Berichte vorhanden.')).toBeInTheDocument()
+ })
+ })
+
+ it('shows loading state', () => {
+ server.use(
+ http.get('/api/reports/list', async () => {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return HttpResponse.json({ items: [], total: 0 })
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/reports' })
+
+ // The heading is always visible
+ expect(screen.getByText('Berichte')).toBeInTheDocument()
+
+ // During loading, the empty state message or report data should not be visible
+ expect(screen.queryByText('Keine Berichte vorhanden.')).not.toBeInTheDocument()
+ expect(screen.queryByText('KW 6')).not.toBeInTheDocument()
+ })
+
+ it('shows error on API failure', async () => {
+ server.use(
+ http.get('/api/reports/list', () => {
+ return new HttpResponse(null, { status: 500 })
+ }),
+ )
+
+ renderWithProviders(, { initialRoute: '/reports' })
+
+ // When query fails, reports defaults to [] so empty state appears
+ await waitFor(() => {
+ expect(screen.getByText('Keine Berichte vorhanden.')).toBeInTheDocument()
+ })
+ })
+})