/** * 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() }) })