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} + + )} + +
+ + setCurrentPassword(e.currentTarget.value)} + required + autoComplete="current-password" + data-testid="current-password-input" + /> + + setNewPassword(e.currentTarget.value)} + required + autoComplete="new-password" + data-testid="new-password-input" + /> + + setConfirmPassword(e.currentTarget.value)} + required + autoComplete="new-password" + data-testid="confirm-password-input" + /> + + + +
+
+
+
+ ) +} 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} + + )} + +
+ + setUsername(e.currentTarget.value)} + required + autoComplete="username" + data-testid="username-input" + /> + + setPassword(e.currentTarget.value)} + required + autoComplete="current-password" + data-testid="password-input" + /> + + + +
+
+
+
) } 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 }, + }, + }) +}