diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7ae9043..19766c1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,17 +5,18 @@
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
*
* Route tree:
- * /login → LoginPage (public, T07 will build the real form)
+ * /login → LoginPage (public)
+ * /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
* / → ProtectedRoute → AppLayout → HomePage (T09)
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
*
- * AppLayout renders a minimal shell with a gear-icon nav entry for /config (§5#10).
- * T07–T10 slot their real pages in without touching the provider/router setup.
+ * AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
*/
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom'
+import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
+import { Button, Group } from '@mantine/core'
// Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css'
@@ -25,6 +26,9 @@ import { ProtectedRoute } from './auth/ProtectedRoute'
import { LoginPage } from './pages/LoginPage'
import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage'
+import { ChangePasswordPage } from './pages/ChangePasswordPage'
+import apiClient from './api/client'
+import { useQueryClient } from '@tanstack/react-query'
// ---------------------------------------------------------------------------
// TanStack Query client (singleton, created outside render to avoid re-creation)
@@ -45,6 +49,32 @@ const queryClient = new QueryClient({
},
})
+// ---------------------------------------------------------------------------
+// Logout button component (needs navigate + queryClient hooks, so it's a component)
+// ---------------------------------------------------------------------------
+
+function LogoutButton() {
+ const navigate = useNavigate()
+ const qc = useQueryClient()
+
+ async function handleLogout() {
+ try {
+ await apiClient.POST('/api/auth/logout')
+ } catch {
+ // Ignore errors on logout — we clear the session regardless.
+ }
+ // Invalidate session so SessionProvider becomes unauthenticated.
+ await qc.invalidateQueries({ queryKey: ['session'] })
+ navigate('/login', { replace: true })
+ }
+
+ return (
+
+ )
+}
+
// ---------------------------------------------------------------------------
// App shell layout (used by all protected pages)
// ---------------------------------------------------------------------------
@@ -52,7 +82,7 @@ const queryClient = new QueryClient({
function AppLayout() {
return (
- {/* Minimal top nav — T07–T10 can enhance this with Mantine AppShell */}
+ {/* Top nav */}
{/* Page content */}
@@ -98,6 +132,16 @@ export default function App() {
{/* Public routes */}
} />
+ {/* Forced password change — protected (must be logged in) but outside AppLayout */}
+
+
+
+ }
+ />
+
{/* Protected routes — all nested under AppLayout */}
+
+
+ )
}
if (status === 'unauthenticated') {
- return
+ // Preserve the intended destination so LoginPage can redirect back after login.
+ return
+ }
+
+ // Authenticated but forced to change password — gate all protected pages.
+ if (user?.force_password_change && location.pathname !== '/change-password') {
+ return
}
return <>{children}>
diff --git a/frontend/src/pages/ChangePasswordPage.test.tsx b/frontend/src/pages/ChangePasswordPage.test.tsx
new file mode 100644
index 0000000..f2919be
--- /dev/null
+++ b/frontend/src/pages/ChangePasswordPage.test.tsx
@@ -0,0 +1,193 @@
+/**
+ * Tests for ChangePasswordPage (M2-T07 rework-1).
+ *
+ * Strategy: vi.mock the apiClient and useSession modules so we can control
+ * POST /api/auth/password responses and session state without a real server.
+ *
+ * Coverage:
+ * 1. Renders the change-password form when user has force_password_change=true.
+ * 2. Successful password change → navigates to '/' (proceeds into the app).
+ * 3. Client-side mismatch → shows error, does NOT call the API.
+ * 4. API 400 error → shows generic error, stays on form.
+ * 5. Guard: non-forced user visiting /change-password → redirected to '/'.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { renderWithProviders } from '../test-utils'
+import { ChangePasswordPage } from './ChangePasswordPage'
+
+// ---------------------------------------------------------------------------
+// Mock apiClient
+// ---------------------------------------------------------------------------
+
+const mockPost = vi.fn()
+
+vi.mock('../api/client', () => ({
+ default: {
+ POST: (...args: unknown[]) => mockPost(...args),
+ GET: vi.fn(),
+ },
+ ApiError: class ApiError extends Error {
+ status: number
+ body: unknown
+ constructor(status: number, body: unknown) {
+ super(`API error ${status}`)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+ },
+ registerLoginRedirect: vi.fn(),
+}))
+
+// ---------------------------------------------------------------------------
+// Mock useSession — default: forced-change user
+// ---------------------------------------------------------------------------
+
+const mockUseSession = vi.fn(() => ({
+ status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated',
+ user: { username: 'admin', force_password_change: true } as
+ | null
+ | { username: string; force_password_change: boolean },
+}))
+
+vi.mock('../auth/SessionProvider', () => ({
+ useSession: () => mockUseSession(),
+ SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function renderChangePw(initialPath = '/change-password') {
+ return renderWithProviders(, {
+ initialPath,
+ routes: [{ path: '/', element: Home
}],
+ })
+}
+
+function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) {
+ fireEvent.change(screen.getByTestId('current-password-input'), {
+ target: { value: currentPw },
+ })
+ fireEvent.change(screen.getByTestId('new-password-input'), {
+ target: { value: newPw },
+ })
+ fireEvent.change(screen.getByTestId('confirm-password-input'), {
+ target: { value: confirmPw },
+ })
+ fireEvent.submit(screen.getByTestId('change-password-form'))
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('ChangePasswordPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Default: authenticated user with force_password_change=true
+ mockUseSession.mockReturnValue({
+ status: 'authenticated',
+ user: { username: 'admin', force_password_change: true },
+ })
+ })
+
+ it('renders the change-password form for a forced-change user', () => {
+ renderChangePw()
+ expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
+ expect(screen.getByTestId('current-password-input')).toBeInTheDocument()
+ expect(screen.getByTestId('new-password-input')).toBeInTheDocument()
+ expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument()
+ expect(screen.getByTestId('change-password-submit')).toBeInTheDocument()
+ })
+
+ it('navigates to "/" after a successful password change', async () => {
+ // Simulate successful POST /api/auth/password
+ mockPost.mockResolvedValueOnce({
+ data: {},
+ response: { status: 200, ok: true },
+ })
+
+ renderChangePw()
+ fillAndSubmit('old-password', 'new-password', 'new-password')
+
+ await waitFor(() => {
+ expect(screen.getByTestId('home-page')).toBeInTheDocument()
+ })
+ })
+
+ it('calls POST /api/auth/password with the correct body', async () => {
+ mockPost.mockResolvedValueOnce({
+ data: {},
+ response: { status: 200, ok: true },
+ })
+
+ renderChangePw()
+ fillAndSubmit('current123', 'newpass456', 'newpass456')
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/api/auth/password', {
+ body: {
+ current_password: 'current123',
+ new_password: 'newpass456',
+ confirm_password: 'newpass456',
+ },
+ })
+ })
+ })
+
+ it('shows error and does NOT call the API when new passwords do not match', async () => {
+ renderChangePw()
+ fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2')
+
+ await waitFor(() => {
+ expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('change-password-error')).toHaveTextContent(
+ /do not match/i,
+ )
+ expect(mockPost).not.toHaveBeenCalled()
+ // Should remain on the form
+ expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
+ })
+
+ it('shows generic error on API 400 and stays on form', async () => {
+ // Simulate 400 via ApiError throw (as the client middleware does)
+ const { ApiError } = await import('../api/client')
+ mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' }))
+
+ renderChangePw()
+ fillAndSubmit('wrong-current', 'newpass', 'newpass')
+
+ await waitFor(() => {
+ expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('change-password-error')).toHaveTextContent(
+ /password change failed/i,
+ )
+ // Should NOT have navigated away
+ expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
+ })
+
+ it('redirects a non-forced user away from /change-password to "/"', async () => {
+ // A user who has already changed their password
+ mockUseSession.mockReturnValue({
+ status: 'authenticated',
+ user: { username: 'admin', force_password_change: false },
+ })
+
+ renderChangePw()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('home-page')).toBeInTheDocument()
+ })
+
+ // The change-password form must NOT be shown
+ expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/ChangePasswordPage.tsx b/frontend/src/pages/ChangePasswordPage.tsx
new file mode 100644
index 0000000..88c0924
--- /dev/null
+++ b/frontend/src/pages/ChangePasswordPage.tsx
@@ -0,0 +1,168 @@
+/**
+ * ChangePasswordPage — forced password change gate (M2-T07).
+ *
+ * Shown when the authenticated user has force_password_change === true.
+ * Blocks access to all other pages until the password is changed.
+ *
+ * Behaviours:
+ * - If the current user does NOT have force_password_change, redirect to '/'
+ * (mirrors LoginPage's already-authenticated guard).
+ * - POST /api/auth/password with { current_password, new_password, confirm_password }.
+ * - On ApiError 400 → show a generic failure message (do not leak details).
+ * - On success → invalidate ['session'] so SessionProvider re-fetches with
+ * force_password_change=false, then navigate to '/' to enter the app.
+ */
+
+import { useState } from 'react'
+import { useNavigate, useLocation, Navigate } from 'react-router-dom'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ Container,
+ Paper,
+ Title,
+ Text,
+ PasswordInput,
+ Button,
+ Alert,
+ Stack,
+ Center,
+} from '@mantine/core'
+import { useSession } from '../auth/SessionProvider'
+import apiClient from '../api/client'
+import { ApiError } from '../api/client'
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface LocationState {
+ from?: { pathname: string }
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function ChangePasswordPage() {
+ const { user } = useSession()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const queryClient = useQueryClient()
+
+ const [currentPassword, setCurrentPassword] = useState('')
+ const [newPassword, setNewPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ // Guard: if the user is authenticated but NOT in forced-change state, redirect
+ // to the app. This prevents a non-forced user from sitting on /change-password.
+ // (Mirrors LoginPage's already-authenticated redirect.)
+ if (user && !user.force_password_change) {
+ const from = (location.state as LocationState)?.from?.pathname ?? '/'
+ return
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+
+ // Client-side validation: confirm passwords match before hitting the server.
+ if (newPassword !== confirmPassword) {
+ setError('New passwords do not match.')
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ await apiClient.POST('/api/auth/password', {
+ body: {
+ current_password: currentPassword,
+ new_password: newPassword,
+ confirm_password: confirmPassword,
+ },
+ })
+
+ // Success: refresh session so force_password_change becomes false,
+ // then navigate into the app — the guard above (and ProtectedRoute) will
+ // no longer block access once the session is updated.
+ await queryClient.invalidateQueries({ queryKey: ['session'] })
+ navigate('/', { replace: true })
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 400) {
+ // Generic failure message — do not leak backend detail.
+ setError('Password change failed. Please check your current password and try again.')
+ } else {
+ setError('An unexpected error occurred. Please try again.')
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Change Password
+
+
+ You must change your password before continuing.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx
new file mode 100644
index 0000000..aa871b9
--- /dev/null
+++ b/frontend/src/pages/LoginPage.test.tsx
@@ -0,0 +1,195 @@
+/**
+ * Tests for LoginPage (M2-T07).
+ *
+ * Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
+ * responses without a real server. We also mock useSession so tests can control
+ * the authentication state.
+ *
+ * Coverage:
+ * 1. Renders the login form.
+ * 2. Successful login → invalidates session query + navigates.
+ * 3. 401 bad credentials → shows inline error, does not navigate.
+ * 4. Already-authenticated users visiting /login → redirected to '/'.
+ * 5. Unexpected error → shows generic error message.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { renderWithProviders } from '../test-utils'
+import { LoginPage } from './LoginPage'
+
+// ---------------------------------------------------------------------------
+// Mock apiClient
+// ---------------------------------------------------------------------------
+
+// We mock the entire api/client module. Each test can override POST as needed.
+const mockPost = vi.fn()
+
+vi.mock('../api/client', () => ({
+ default: {
+ POST: (...args: unknown[]) => mockPost(...args),
+ GET: vi.fn(),
+ },
+ ApiError: class ApiError extends Error {
+ status: number
+ body: unknown
+ constructor(status: number, body: unknown) {
+ super(`API error ${status}`)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+ },
+ registerLoginRedirect: vi.fn(),
+}))
+
+// ---------------------------------------------------------------------------
+// Mock useSession — default: unauthenticated
+// ---------------------------------------------------------------------------
+
+// Typed as returning the wider union so mockReturnValue accepts all status variants.
+const mockUseSession = vi.fn(() => ({
+ status: 'unauthenticated' as 'loading' | 'authenticated' | 'unauthenticated',
+ user: null as null | { username: string; force_password_change: boolean },
+}))
+
+vi.mock('../auth/SessionProvider', () => ({
+ useSession: () => mockUseSession(),
+ SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function renderLogin(initialPath = '/login') {
+ return renderWithProviders(, {
+ initialPath,
+ routes: [{ path: '/', element: Home
}],
+ })
+}
+
+function fillAndSubmit(username: string, password: string) {
+ fireEvent.change(screen.getByTestId('username-input'), { target: { value: username } })
+ fireEvent.change(screen.getByTestId('password-input'), { target: { value: password } })
+ fireEvent.submit(screen.getByTestId('login-form'))
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Reset to unauthenticated by default
+ mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
+ })
+
+ it('renders the login form with username and password fields', () => {
+ renderLogin()
+ expect(screen.getByTestId('login-form')).toBeInTheDocument()
+ expect(screen.getByTestId('username-input')).toBeInTheDocument()
+ expect(screen.getByTestId('password-input')).toBeInTheDocument()
+ expect(screen.getByTestId('login-submit')).toBeInTheDocument()
+ })
+
+ it('shows Sign In heading', () => {
+ renderLogin()
+ expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
+ })
+
+ it('navigates to "/" on successful login', async () => {
+ // Simulate a successful POST /api/auth/login response
+ mockPost.mockResolvedValueOnce({
+ data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
+ response: { status: 200, ok: true },
+ })
+
+ renderLogin()
+ fillAndSubmit('admin', 'correct-password')
+
+ await waitFor(() => {
+ expect(screen.getByTestId('home-page')).toBeInTheDocument()
+ })
+ })
+
+ it('calls POST /api/auth/login with the correct body', async () => {
+ mockPost.mockResolvedValueOnce({
+ data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
+ response: { status: 200, ok: true },
+ })
+
+ renderLogin()
+ fillAndSubmit('myuser', 'mypassword')
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
+ body: { username: 'myuser', password: 'mypassword' },
+ })
+ })
+ })
+
+ it('shows inline error on 401 and does NOT navigate', async () => {
+ // Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
+ mockPost.mockResolvedValueOnce({
+ data: undefined,
+ response: { status: 401, ok: false },
+ })
+
+ renderLogin()
+ fillAndSubmit('admin', 'wrong-password')
+
+ await waitFor(() => {
+ expect(screen.getByTestId('login-error')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('login-error')).toHaveTextContent(
+ /incorrect username or password/i,
+ )
+ // Should still be on the login form, not navigated away
+ expect(screen.getByTestId('login-form')).toBeInTheDocument()
+ })
+
+ it('does not include the password in the error message', async () => {
+ mockPost.mockResolvedValueOnce({
+ data: undefined,
+ response: { status: 401, ok: false },
+ })
+
+ renderLogin()
+ fillAndSubmit('admin', 'super-secret-password')
+
+ await waitFor(() => {
+ expect(screen.getByTestId('login-error')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
+ })
+
+ it('shows generic error on unexpected network failure', async () => {
+ mockPost.mockRejectedValueOnce(new Error('Network error'))
+
+ renderLogin()
+ fillAndSubmit('admin', 'password')
+
+ await waitFor(() => {
+ expect(screen.getByTestId('login-error')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
+ })
+
+ it('redirects already-authenticated users to "/"', async () => {
+ mockUseSession.mockReturnValue({
+ status: 'authenticated',
+ user: { username: 'admin', force_password_change: false },
+ })
+
+ renderLogin()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('home-page')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index 362230f..14afdd5 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -1,19 +1,147 @@
/**
- * LoginPage — placeholder for M2-T07.
+ * LoginPage — real login form (M2-T07).
*
- * T07 replaces this with the real login form (username/password → POST /api/auth/login,
- * force-password-change flow, redirect to home on success).
+ * Behaviours:
+ * - Renders a Mantine form with username + password fields.
+ * - On submit → POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
+ * - On success → invalidate ['session'] so SessionProvider re-fetches, then navigate to the
+ * originally-requested route (from location.state.from) or fall back to '/'.
+ * - On 401 (bad credentials) → show an inline error without leaking the password.
+ * - Already-authenticated users visiting /login → redirect to '/'.
*/
-import { Container, Title, Text } from '@mantine/core'
+import { useState } from 'react'
+import { useNavigate, useLocation, Navigate } from 'react-router-dom'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ Container,
+ Paper,
+ Title,
+ TextInput,
+ PasswordInput,
+ Button,
+ Alert,
+ Stack,
+ Center,
+} from '@mantine/core'
+import { useSession } from '../auth/SessionProvider'
+import apiClient from '../api/client'
+import { setCsrfToken } from '../api/csrf'
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface LocationState {
+ from?: { pathname: string }
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
export function LoginPage() {
+ const { status } = useSession()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const queryClient = useQueryClient()
+
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ // Already authenticated → redirect to intended destination or home.
+ if (status === 'authenticated') {
+ const from = (location.state as LocationState)?.from?.pathname ?? '/'
+ return
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+ setLoading(true)
+
+ try {
+ const res = await apiClient.POST('/api/auth/login', {
+ body: { username, password },
+ })
+
+ if (res.response.status === 401 || !res.data) {
+ // Bad credentials — do not leak the password in the message.
+ setError('Incorrect username or password.')
+ return
+ }
+
+ // Success: store the CSRF token returned by login (same shape as session response).
+ if (res.data.csrf_token) {
+ setCsrfToken(res.data.csrf_token)
+ }
+
+ // Refresh session state: invalidate the ['session'] query so SessionProvider
+ // picks up the new authenticated state (which may include force_password_change).
+ await queryClient.invalidateQueries({ queryKey: ['session'] })
+
+ // Navigate to the originally-requested route or home.
+ const from = (location.state as LocationState)?.from?.pathname ?? '/'
+ navigate(from, { replace: true })
+ } catch {
+ // Any unexpected error (network, 5xx, etc.)
+ setError('Login failed. Please try again.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
return (
-
- Login
-
- Login form — implemented in M2-T07.
-
-
+
+
+
+
+ Sign In
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
)
}
diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts
index 0048b42..5854dde 100644
--- a/frontend/src/test-setup.ts
+++ b/frontend/src/test-setup.ts
@@ -1,5 +1,38 @@
/**
* Vitest global setup file.
* Imports @testing-library/jest-dom to extend vitest matchers with DOM assertions.
+ *
+ * Also polyfills browser APIs that jsdom does not implement but Mantine needs:
+ * - window.matchMedia (Mantine uses it for color-scheme detection)
+ * - ResizeObserver (Mantine uses it for responsive components)
*/
import '@testing-library/jest-dom'
+
+// ---------------------------------------------------------------------------
+// window.matchMedia polyfill (jsdom does not implement this)
+// ---------------------------------------------------------------------------
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+})
+
+// ---------------------------------------------------------------------------
+// ResizeObserver polyfill (jsdom does not implement this)
+// ---------------------------------------------------------------------------
+if (typeof ResizeObserver === 'undefined') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(globalThis as any).ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+}
diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx
new file mode 100644
index 0000000..49c3e90
--- /dev/null
+++ b/frontend/src/test-utils.tsx
@@ -0,0 +1,83 @@
+/**
+ * Shared test utilities — wraps components in the providers they need.
+ *
+ * Usage:
+ * import { renderWithProviders } from '../test-utils'
+ * renderWithProviders(, { initialPath: '/login' })
+ */
+
+import type { ReactNode } from 'react'
+import { render } from '@testing-library/react'
+import { MantineProvider } from '@mantine/core'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter, Routes, Route } from 'react-router-dom'
+
+// ---------------------------------------------------------------------------
+// Provider wrapper
+// ---------------------------------------------------------------------------
+
+interface RenderOptions {
+ /** Initial URL path (default: '/'). */
+ initialPath?: string
+ /**
+ * Extra routes to register alongside the component under test.
+ * Useful for asserting navigation (e.g. render a /home sentinel and check
+ * that the component navigates there after login).
+ */
+ routes?: Array<{ path: string; element: ReactNode }>
+ /**
+ * React-router initial entries (overrides initialPath when provided).
+ * Use when you need to seed location.state (e.g. from-path for redirect-after-login).
+ */
+ initialEntries?: Array
+}
+
+/**
+ * Render `ui` inside MantineProvider + a fresh QueryClientProvider + MemoryRouter.
+ * SessionProvider is NOT included — tests that need session state should mock
+ * `GET /api/session` via vi.fn() on the apiClient or use MSW.
+ */
+export function renderWithProviders(ui: ReactNode, options: RenderOptions = {}) {
+ const { initialPath = '/', routes = [], initialEntries } = options
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+ const entries = initialEntries ?? [initialPath]
+
+ function Wrapper() {
+ return (
+
+
+
+
+
+ {routes.map(({ path, element }) => (
+
+ ))}
+
+
+
+
+ )
+ }
+
+ return render()
+}
+
+/**
+ * Create a minimal SessionProvider-less wrapper that just supplies the
+ * query and router context. Returns the queryClient so tests can prime it.
+ */
+export function createTestQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+}