From 4d641cae7fd17ee78bc7c60ab88a6fa6cda30b90 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 28 Jun 2026 16:33:32 +0200 Subject: [PATCH] 52 - Add register screen with shared field-error parsing --- frontend/src/auth/RegisterPage.test.tsx | 172 ++++++++++++++++++++++ frontend/src/auth/RegisterPage.tsx | 93 ++++++++++++ frontend/src/auth/RegisterPlaceholder.tsx | 4 - frontend/src/components/ui/FieldError.tsx | 26 ++++ frontend/src/lib/fieldErrors.test.ts | 46 ++++++ frontend/src/lib/fieldErrors.ts | 45 ++++++ frontend/src/router.tsx | 4 +- 7 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 frontend/src/auth/RegisterPage.test.tsx create mode 100644 frontend/src/auth/RegisterPage.tsx delete mode 100644 frontend/src/auth/RegisterPlaceholder.tsx create mode 100644 frontend/src/components/ui/FieldError.tsx create mode 100644 frontend/src/lib/fieldErrors.test.ts create mode 100644 frontend/src/lib/fieldErrors.ts diff --git a/frontend/src/auth/RegisterPage.test.tsx b/frontend/src/auth/RegisterPage.test.tsx new file mode 100644 index 0000000..7ada828 --- /dev/null +++ b/frontend/src/auth/RegisterPage.test.tsx @@ -0,0 +1,172 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { delay, http, HttpResponse } from 'msw' +import { MemoryRouter, Route, Routes } from 'react-router' +import { describe, expect, it } from 'vitest' +import { server } from '../test/server' +import { AuthProvider } from './AuthProvider' +import { RequireGuest } from './guards' +import RegisterPage from './RegisterPage' + +/** + * Route tree mirroring the real app skeleton: a guest-only /register rendering + * the real RegisterPage, and a /login route showing a marker so we can assert + * the post-register navigation actually lands there. Unlike login, a + * successful register does NOT flip auth state — it must explicitly navigate. + */ +function AppWithRegister() { + return ( + + + }> + } /> + LOGIN} /> + + + + ) +} + +describe('RegisterPage', () => { + it('registers with valid input and redirects to the login screen', async () => { + let registerBody: unknown + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + http.post('http://localhost/api/register', async ({ request }) => { + registerBody = await request.json() + return HttpResponse.json( + { id: 'u1', email: 'new@example.com' }, + { status: 201 }, + ) + }), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + await user.type(await screen.findByLabelText(/email/i), 'new@example.com') + await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery') + await user.click(screen.getByRole('button', { name: /register/i })) + + expect(await screen.findByText('LOGIN')).toBeInTheDocument() + expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument() + + // The register endpoint takes email/password — NOT username (login's quirk). + expect(registerBody).toEqual({ + email: 'new@example.com', + password: 'correct-horse-battery', + }) + }) + + it('renders server-side field errors from a 422', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + http.post('http://localhost/api/register', () => + HttpResponse.json( + { + errors: { + email: ['This email is already registered.'], + password: ['Too short.'], + }, + }, + { status: 422 }, + ), + ), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + await user.type(await screen.findByLabelText(/email/i), 'taken@example.com') + await user.type(screen.getByLabelText(/password/i), 'short') + await user.click(screen.getByRole('button', { name: /register/i })) + + expect( + await screen.findByText(/this email is already registered/i), + ).toBeInTheDocument() + expect(screen.getByText(/too short/i)).toBeInTheDocument() + + // Still on /register — no redirect happened. + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.queryByText('LOGIN')).not.toBeInTheDocument() + }) + + it('shows a duplicate-email error from a 422', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + http.post('http://localhost/api/register', () => + HttpResponse.json( + { errors: { email: ['This email is already registered.'] } }, + { status: 422 }, + ), + ), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + await user.type(await screen.findByLabelText(/email/i), 'taken@example.com') + await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery') + await user.click(screen.getByRole('button', { name: /register/i })) + + expect( + await screen.findByText(/this email is already registered/i), + ).toBeInTheDocument() + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.queryByText('LOGIN')).not.toBeInTheDocument() + }) + + it('disables the submit button while the request is in flight', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + http.post('http://localhost/api/register', async () => { + await delay(50) + return HttpResponse.json( + { id: 'u1', email: 'new@example.com' }, + { status: 201 }, + ) + }), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + await user.type(await screen.findByLabelText(/email/i), 'new@example.com') + await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery') + + const submitButton = screen.getByRole('button', { name: /register/i }) + await user.click(submitButton) + + expect(submitButton).toBeDisabled() + + expect(await screen.findByText('LOGIN')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/auth/RegisterPage.tsx b/frontend/src/auth/RegisterPage.tsx new file mode 100644 index 0000000..60749e5 --- /dev/null +++ b/frontend/src/auth/RegisterPage.tsx @@ -0,0 +1,93 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router' +import AppLayout from '../components/layout/AppLayout' +import Button from '../components/ui/Button' +import FieldError from '../components/ui/FieldError' +import Input from '../components/ui/Input' +import Panel from '../components/ui/Panel' +import { api } from '../lib/api' +import { parseFieldErrors, type FieldErrors } from '../lib/fieldErrors' + +export default function RegisterPage() { + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [fieldErrors, setFieldErrors] = useState({}) + const [formError, setFormError] = useState(null) + const [pending, setPending] = useState(false) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setFieldErrors({}) + setFormError(null) + setPending(true) + + try { + await api.postJson('/register', { email, password }) + // Register does not establish a session — send the user to sign in. + navigate('/login') + } catch (err: unknown) { + const errors = parseFieldErrors(err) + if (errors !== null) { + setFieldErrors(errors) + } else { + setFormError('Registration failed. Please try again.') + } + } finally { + setPending(false) + } + } + + const emailErrors = fieldErrors.email + const passwordErrors = fieldErrors.password + + return ( + + +

+ Register +

+ +
+
+ setEmail(e.target.value)} + aria-invalid={emailErrors !== undefined} + aria-describedby={emailErrors ? 'email-errors' : undefined} + /> + +
+ +
+ setPassword(e.target.value)} + aria-invalid={passwordErrors !== undefined} + aria-describedby={passwordErrors ? 'password-errors' : undefined} + /> + +
+ + {formError !== null && ( +

+ {formError} +

+ )} + + +
+
+
+ ) +} diff --git a/frontend/src/auth/RegisterPlaceholder.tsx b/frontend/src/auth/RegisterPlaceholder.tsx deleted file mode 100644 index f11a744..0000000 --- a/frontend/src/auth/RegisterPlaceholder.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// Placeholder replaced by the real register screen in #52 Story 5. -export default function RegisterPlaceholder() { - return
REGISTER
-} diff --git a/frontend/src/components/ui/FieldError.tsx b/frontend/src/components/ui/FieldError.tsx new file mode 100644 index 0000000..410369b --- /dev/null +++ b/frontend/src/components/ui/FieldError.tsx @@ -0,0 +1,26 @@ +interface FieldErrorProps { + /** Used as the element id so an input can reference it via aria-describedby. */ + id?: string + messages?: string[] +} + +/** + * Renders a form field's validation messages (from a 422 field-error map). + * Renders nothing when there are no messages. Each message is a live-region + * alert so screen readers announce it when it appears. + */ +export default function FieldError({ id, messages }: FieldErrorProps) { + if (messages === undefined || messages.length === 0) { + return null + } + + return ( +
    + {messages.map((message, index) => ( +
  • + {message} +
  • + ))} +
+ ) +} diff --git a/frontend/src/lib/fieldErrors.test.ts b/frontend/src/lib/fieldErrors.test.ts new file mode 100644 index 0000000..a9b1a2e --- /dev/null +++ b/frontend/src/lib/fieldErrors.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { ApiError } from './api' +import { parseFieldErrors } from './fieldErrors' + +function error422(body: unknown): ApiError { + return new ApiError(422, JSON.stringify(body)) +} + +describe('parseFieldErrors', () => { + it('extracts a well-formed field-error map from a 422', () => { + const result = parseFieldErrors( + error422({ errors: { email: ['Already taken.'], password: ['Short.'] } }), + ) + + expect(result).toEqual({ + email: ['Already taken.'], + password: ['Short.'], + }) + }) + + it('returns null for a non-ApiError', () => { + expect(parseFieldErrors(new Error('boom'))).toBeNull() + }) + + it('returns null for a non-422 ApiError', () => { + expect(parseFieldErrors(new ApiError(500, 'server error'))).toBeNull() + }) + + it('returns null when the body is not JSON', () => { + expect(parseFieldErrors(new ApiError(422, 'nope'))).toBeNull() + }) + + it('returns null when the body has no errors key', () => { + expect(parseFieldErrors(error422({ message: 'bad' }))).toBeNull() + }) + + it('returns null when a field value is a bare string, not an array', () => { + expect( + parseFieldErrors(error422({ errors: { email: 'taken' } })), + ).toBeNull() + }) + + it('returns null when a field value is an array of non-strings', () => { + expect(parseFieldErrors(error422({ errors: { email: [42] } }))).toBeNull() + }) +}) diff --git a/frontend/src/lib/fieldErrors.ts b/frontend/src/lib/fieldErrors.ts new file mode 100644 index 0000000..2e573fd --- /dev/null +++ b/frontend/src/lib/fieldErrors.ts @@ -0,0 +1,45 @@ +import { ApiError } from './api' + +/** Per-field validation messages: a 422 body's `{ errors: { field: [msg] } }`. */ +export type FieldErrors = Record + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +/** + * Extract the `{ errors: { field: [...] } }` map from a 422 ApiError body. + * Returns null for anything that isn't a well-formed 422 field-error response + * (wrong status, non-ApiError, non-JSON body, or a malformed errors shape) so + * the caller can fall back to a generic error message. + */ +export function parseFieldErrors(error: unknown): FieldErrors | null { + if (!(error instanceof ApiError) || error.status !== 422) { + return null + } + + let parsed: unknown + try { + parsed = JSON.parse(error.body) + } catch { + return null + } + + if ( + typeof parsed !== 'object' || + parsed === null || + !('errors' in parsed) || + typeof parsed.errors !== 'object' || + parsed.errors === null + ) { + return null + } + + // Only accept the contract shape: every field maps to an array of strings. + const entries = Object.entries(parsed.errors as Record) + if (!entries.every(([, messages]) => isStringArray(messages))) { + return null + } + + return parsed.errors as FieldErrors +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 13005d5..d33ac3b 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,6 +1,6 @@ import { createBrowserRouter, Navigate, type RouteObject } from 'react-router' import LoginPage from './auth/LoginPage' -import RegisterPlaceholder from './auth/RegisterPlaceholder' +import RegisterPage from './auth/RegisterPage' import { RequireAuth, RequireGuest } from './auth/guards' import ScenariosPage from './pages/ScenariosPage' @@ -18,7 +18,7 @@ export const routes: RouteObject[] = [ element: , children: [ { path: '/login', element: }, - { path: '/register', element: }, + { path: '/register', element: }, ], }, {