From f0efa25f23a4cabf75a3ff2a0f4db248c44c74bf Mon Sep 17 00:00:00 2001 From: Malcolm Roberts Date: Fri, 2 Oct 2026 14:23:42 -0500 Subject: [PATCH] feat: add sign-in flow, routing shell, and searchable book list Pin @testing-library/jest-dom to ~6.9.1 (6.10.0 is npm-deprecated). Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X --- package-lock.json | 14 ++--- package.json | 2 +- src/App.tsx | 65 ++++++++++++++++++- src/api.ts | 54 ++++++++++++++++ src/auth/AuthContext.tsx | 42 +++++++++++++ src/auth/LoginForm.tsx | 85 +++++++++++++++++++++++++ src/auth/RequireAuth.tsx | 10 +++ src/auth/auth.test.tsx | 69 ++++++++++++++++++++ src/books/BookList.test.tsx | 58 +++++++++++++++++ src/books/BookList.tsx | 122 ++++++++++++++++++++++++++++++++++++ src/books/Progress.tsx | 30 +++++++++ src/books/useGenres.ts | 11 ++++ src/test/helpers.tsx | 65 +++++++++++++++++++ src/ui.ts | 9 +++ 14 files changed, 625 insertions(+), 11 deletions(-) create mode 100644 src/api.ts create mode 100644 src/auth/AuthContext.tsx create mode 100644 src/auth/LoginForm.tsx create mode 100644 src/auth/RequireAuth.tsx create mode 100644 src/auth/auth.test.tsx create mode 100644 src/books/BookList.test.tsx create mode 100644 src/books/BookList.tsx create mode 100644 src/books/Progress.tsx create mode 100644 src/books/useGenres.ts create mode 100644 src/test/helpers.tsx create mode 100644 src/ui.ts diff --git a/package-lock.json b/package-lock.json index 230c645..957034d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", - "@testing-library/jest-dom": "^6.10.0", + "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^24.10.1", @@ -1768,10 +1768,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz", - "integrity": "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==", - "deprecated": "Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0.", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -1783,12 +1782,9 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=22", + "node": ">=14", "npm": ">=6", "yarn": ">=1" - }, - "peerDependencies": { - "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { diff --git a/package.json b/package.json index 27a132f..877c47e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", - "@testing-library/jest-dom": "^6.10.0", + "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^24.10.1", diff --git a/src/App.tsx b/src/App.tsx index 6a5441a..3c91ae6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,66 @@ +import { useState } from 'react' +import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from 'react-router' +import { errorMessage } from './api' +import { AuthProvider, useAuth } from './auth/AuthContext' +import { LoginForm } from './auth/LoginForm' +import { RequireAuth } from './auth/RequireAuth' +import { BookList } from './books/BookList' +import { secondaryButton } from './ui' + export function App() { - return

My Library

+ return ( + + + + + + ) +} + +export function AppRoutes() { + return ( + + } /> + }> + }> + } /> + + + } /> + + ) +} + +function Shell() { + const { user, logout } = useAuth() + const [error, setError] = useState(null) + return ( +
+
+
+ + My Library + +
+ {user?.username} + +
+
+ {error && ( +

+ {error} +

+ )} +
+
+ +
+
+ ) } diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..2342afd --- /dev/null +++ b/src/api.ts @@ -0,0 +1,54 @@ +export type User = { id: number; username: string } +export type Genre = { id: number; name: string } +export type ReadingStatus = 'not_started' | 'reading' | 'finished' +export type Book = { + id: number + title: string + author: string + genre: Genre + total_pages: number + current_page: number + status: ReadingStatus + created_at: string + updated_at: string +} +export type Note = { id: number; book_id: number; body: string; created_at: string; updated_at: string } + +export class ApiError extends Error { + readonly status: number + readonly field?: string + + constructor(status: number, message: string, field?: string) { + super(message) + this.status = status + this.field = field + } +} + +let handleUnauthorized: () => void = () => {} + +export function setUnauthorizedHandler(handler: () => void) { + handleUnauthorized = handler +} + +export async function api(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise { + const response = await fetch(`/api${path}`, { + method, + credentials: 'same-origin', + // Always JSON: the server rejects mutations without it (CSRF defense, ADR-0002). + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (response.status === 204) return undefined as T + const data = await response.json().catch(() => null) + if (!response.ok) { + // 401s from /auth/* are expected answers (bad login, not signed in yet), not an expired session. + if (response.status === 401 && !path.startsWith('/auth/')) handleUnauthorized() + throw new ApiError(response.status, data?.error ?? `Request failed with status ${response.status}`, data?.field) + } + return data as T +} + +export function errorMessage(error: unknown): string { + return error instanceof ApiError ? error.message : 'Could not reach the server; check your connection and try again' +} diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx new file mode 100644 index 0000000..cd8daac --- /dev/null +++ b/src/auth/AuthContext.tsx @@ -0,0 +1,42 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { api, setUnauthorizedHandler, type User } from '../api' + +type AuthValue = { + user: User | null + loading: boolean + login: (username: string, password: string) => Promise + register: (username: string, password: string) => Promise + logout: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setUnauthorizedHandler(() => setUser(null)) + api('GET', '/auth/me') + .then(setUser, () => setUser(null)) + .finally(() => setLoading(false)) + }, []) + + const value: AuthValue = { + user, + loading, + login: async (username, password) => setUser(await api('POST', '/auth/login', { username, password })), + register: async (username, password) => setUser(await api('POST', '/auth/register', { username, password })), + logout: async () => { + await api('POST', '/auth/logout') + setUser(null) + }, + } + return {children} +} + +export function useAuth(): AuthValue { + const value = useContext(AuthContext) + if (!value) throw new Error('useAuth must be used inside ') + return value +} diff --git a/src/auth/LoginForm.tsx b/src/auth/LoginForm.tsx new file mode 100644 index 0000000..4377d66 --- /dev/null +++ b/src/auth/LoginForm.tsx @@ -0,0 +1,85 @@ +import { useState, type FormEvent } from 'react' +import { Navigate, useLocation, type Location } from 'react-router' +import { errorMessage } from '../api' +import { card, inputClass, labelText, primaryButton } from '../ui' +import { useAuth } from './AuthContext' + +export function LoginForm() { + const { user, login, register } = useAuth() + const location = useLocation() + const [mode, setMode] = useState<'login' | 'register'>('login') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + if (user) { + const from = (location.state as { from?: Location } | null)?.from + return + } + + const isLogin = mode === 'login' + + async function submit(event: FormEvent) { + event.preventDefault() + setError(null) + setBusy(true) + try { + await (isLogin ? login : register)(username, password) + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(false) + } + } + + return ( +
+
+

{isLogin ? 'Sign in' : 'Create account'}

+
+ + + {error && ( +

+ {error} +

+ )} + +
+ +
+
+ ) +} diff --git a/src/auth/RequireAuth.tsx b/src/auth/RequireAuth.tsx new file mode 100644 index 0000000..af640df --- /dev/null +++ b/src/auth/RequireAuth.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet, useLocation } from 'react-router' +import { useAuth } from './AuthContext' + +export function RequireAuth() { + const { user, loading } = useAuth() + const location = useLocation() + if (loading) return

Loading…

+ if (!user) return + return +} diff --git a/src/auth/auth.test.tsx b/src/auth/auth.test.tsx new file mode 100644 index 0000000..c2b0860 --- /dev/null +++ b/src/auth/auth.test.tsx @@ -0,0 +1,69 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { alice, dune, genres, mockApi, renderApp, signedIn } from '../test/helpers' + +describe('auth flow', () => { + it('sends signed-out users to sign in, then back to the page they wanted', async () => { + const calls = mockApi({ + 'GET /api/auth/me': () => [401, { error: 'Not signed in or session expired; log in again' }], + 'POST /api/auth/login': () => [200, alice], + 'GET /api/genres': () => [200, genres], + 'GET /api/books': () => [200, [dune]], + }) + const user = renderApp('/books?q=dune') + + await screen.findByRole('heading', { name: 'Sign in' }) + await user.type(screen.getByLabelText('Username'), 'alice') + await user.type(screen.getByLabelText('Password'), 'correct horse battery') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + + expect(await screen.findByLabelText('Search title or author')).toHaveValue('dune') + expect(calls).toContainEqual({ + method: 'POST', + path: '/api/auth/login', + body: { username: 'alice', password: 'correct horse battery' }, + }) + }) + + it('shows the server error for a bad login', async () => { + mockApi({ + 'GET /api/auth/me': () => [401, { error: 'Not signed in' }], + 'POST /api/auth/login': () => [401, { error: 'Invalid username or password' }], + }) + const user = renderApp('/login') + await user.type(await screen.findByLabelText('Username'), 'alice') + await user.type(screen.getByLabelText('Password'), 'wrong password!') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Invalid username or password') + }) + + it('registers a new account', async () => { + const calls = mockApi({ + 'GET /api/auth/me': () => [401, { error: 'Not signed in' }], + 'POST /api/auth/register': () => [201, alice], + 'GET /api/genres': () => [200, genres], + 'GET /api/books': () => [200, []], + }) + const user = renderApp('/login') + await user.click(await screen.findByRole('button', { name: 'Need an account? Register' })) + await user.type(screen.getByLabelText('Username'), 'alice') + await user.type(screen.getByLabelText('Password'), 'correct horse battery') + await user.click(screen.getByRole('button', { name: 'Create account' })) + expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument() + expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/register')).toBe(true) + }) + + it('returns to sign in when the session expires mid-use', async () => { + signedIn({ 'GET /api/books': () => [401, { error: 'Not signed in or session expired; log in again' }] }) + renderApp('/books') + expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + }) + + it('logs out', async () => { + const calls = signedIn({ 'GET /api/books': () => [200, []], 'POST /api/auth/logout': () => [204] }) + const user = renderApp('/books') + await user.click(await screen.findByRole('button', { name: 'Log out' })) + expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/logout')).toBe(true) + }) +}) diff --git a/src/books/BookList.test.tsx b/src/books/BookList.test.tsx new file mode 100644 index 0000000..5471507 --- /dev/null +++ b/src/books/BookList.test.tsx @@ -0,0 +1,58 @@ +import { screen, waitFor } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { dune, renderApp, signedIn } from '../test/helpers' + +describe('BookList', () => { + it('shows each book with author, genre, status, and progress', async () => { + signedIn({ 'GET /api/books': () => [200, [dune]] }) + renderApp('/books') + expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument() + expect(screen.getByText('Frank Herbert · Science Fiction')).toBeInTheDocument() + expect(screen.getByText('Reading')).toBeInTheDocument() + expect(screen.getByText('142 / 380 (37%)')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Dune/ })).toHaveAttribute('href', '/books/7') + }) + + it('distinguishes an empty library from no matches', async () => { + signedIn({ 'GET /api/books': () => [200, []] }) + const user = renderApp('/books') + expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument() + await user.type(screen.getByLabelText('Search title or author'), 'zzz') + expect(await screen.findByText('No matches. Try a different search or genre.')).toBeInTheDocument() + }) + + it('debounces search into the URL and the request', async () => { + const calls = signedIn({ 'GET /api/books': () => [200, [dune]] }) + const user = renderApp('/books') + await screen.findByRole('heading', { name: 'Dune' }) + await user.type(screen.getByLabelText('Search title or author'), 'herbert') + await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?q=herbert')) + expect(calls.filter((c) => c.path.startsWith('/api/books?q=h')).map((c) => c.path)).toEqual(['/api/books?q=herbert']) + }) + + it('filters by genre', async () => { + const calls = signedIn({ 'GET /api/books': () => [200, [dune]] }) + const user = renderApp('/books') + await screen.findByRole('option', { name: 'Science Fiction' }) + await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction') + await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?genre_id=5')) + }) + + it('restores search and genre from the URL', async () => { + const calls = signedIn({ 'GET /api/books': () => [200, [dune]] }) + renderApp('/books?q=dune&genre=5') + await screen.findByRole('option', { name: 'Science Fiction' }) + expect(screen.getByLabelText('Search title or author')).toHaveValue('dune') + expect(screen.getByLabelText('Genre')).toHaveValue('5') + expect(calls.map((c) => c.path)).toContain('/api/books?q=dune&genre_id=5') + }) + + it('offers retry when loading fails', async () => { + let attempts = 0 + signedIn({ 'GET /api/books': () => (++attempts === 1 ? [500, { error: 'Unexpected server error' }] : [200, [dune]]) }) + const user = renderApp('/books') + expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected server error') + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument() + }) +}) diff --git a/src/books/BookList.tsx b/src/books/BookList.tsx new file mode 100644 index 0000000..df5543d --- /dev/null +++ b/src/books/BookList.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router' +import { api, errorMessage, type Book } from '../api' +import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui' +import { ProgressBar, StatusBadge } from './Progress' +import { useGenres } from './useGenres' + +const SEARCH_DEBOUNCE_MS = 250 + +export function BookList() { + const [params, setParams] = useSearchParams() + const q = params.get('q') ?? '' + const genre = params.get('genre') ?? '' + const [search, setSearch] = useState(q) + const [books, setBooks] = useState(null) + const [error, setError] = useState(null) + const [attempt, setAttempt] = useState(0) + const { genres, error: genresError } = useGenres() + + function updateParam(key: string, value: string) { + setParams( + (prev) => { + const next = new URLSearchParams(prev) + if (value) next.set(key, value) + else next.delete(key) + return next + }, + { replace: true }, + ) + } + + // Keep the input in sync when the URL changes from outside (back/forward navigation). + useEffect(() => setSearch(q), [q]) + + useEffect(() => { + if (search === q) return + const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [search, q]) + + useEffect(() => { + let cancelled = false + const query = new URLSearchParams() + if (q.trim()) query.set('q', q.trim()) + if (genre) query.set('genre_id', genre) + api('GET', `/books?${query}`).then( + (found) => { + if (cancelled) return + setBooks(found) + setError(null) + }, + (err) => { + if (!cancelled) setError(errorMessage(err)) + }, + ) + return () => { + cancelled = true + } + }, [q, genre, attempt]) + + return ( +
+
+ + + + Add book + +
+ {genresError && ( +

+ {`Could not load genres: ${genresError}`} +

+ )} + {error ? ( +
+ {error} + +
+ ) : books === null ? ( +

Loading…

+ ) : books.length === 0 ? ( +

+ {q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'} +

+ ) : ( +
    + {books.map((book) => ( +
  • + +
    +

    {book.title}

    + +
    +

    {`${book.author} · ${book.genre.name}`}

    + + +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/books/Progress.tsx b/src/books/Progress.tsx new file mode 100644 index 0000000..bba1a2d --- /dev/null +++ b/src/books/Progress.tsx @@ -0,0 +1,30 @@ +import type { Book, ReadingStatus } from '../api' + +const STATUS: Record = { + not_started: { label: 'Not started', style: 'bg-slate-100 text-slate-700' }, + reading: { label: 'Reading', style: 'bg-amber-100 text-amber-800' }, + finished: { label: 'Finished', style: 'bg-emerald-100 text-emerald-800' }, +} + +export function StatusBadge({ status }: { status: ReadingStatus }) { + return ( + + {STATUS[status].label} + + ) +} + +export function ProgressBar({ book }: { book: Book }) { + const percent = Math.round((book.current_page / book.total_pages) * 100) + return ( +
+ +

{`${book.current_page} / ${book.total_pages} (${percent}%)`}

+
+ ) +} diff --git a/src/books/useGenres.ts b/src/books/useGenres.ts new file mode 100644 index 0000000..c07fec4 --- /dev/null +++ b/src/books/useGenres.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react' +import { api, errorMessage, type Genre } from '../api' + +export function useGenres(): { genres: Genre[]; error: string | null } { + const [genres, setGenres] = useState([]) + const [error, setError] = useState(null) + useEffect(() => { + api('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err))) + }, []) + return { genres, error } +} diff --git a/src/test/helpers.tsx b/src/test/helpers.tsx new file mode 100644 index 0000000..6793f1b --- /dev/null +++ b/src/test/helpers.tsx @@ -0,0 +1,65 @@ +import { render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' +import { vi } from 'vitest' +import type { Book, Genre, User } from '../api' +import { AppRoutes } from '../App' +import { AuthProvider } from '../auth/AuthContext' + +export const alice: User = { id: 1, username: 'alice' } +export const genres: Genre[] = [ + { id: 3, name: 'Fantasy' }, + { id: 5, name: 'Science Fiction' }, +] +export const dune: Book = { + id: 7, + title: 'Dune', + author: 'Frank Herbert', + genre: { id: 5, name: 'Science Fiction' }, + total_pages: 380, + current_page: 142, + status: 'reading', + created_at: '2026-10-01T10:00:00+00:00', + updated_at: '2026-10-01T10:00:00+00:00', +} + +type Reply = [status: number, body?: unknown] +type Handler = (request: { body: unknown; url: URL }) => Reply +export type Call = { method: string; path: string; body: unknown } + +/** Replaces fetch (the network boundary) with route handlers keyed by "METHOD /api/path". */ +export function mockApi(routes: Record): Call[] { + const calls: Call[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string, init: RequestInit = {}) => { + const url = new URL(input, 'http://localhost') + const method = init.method ?? 'GET' + const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined + calls.push({ method, path: url.pathname + url.search, body }) + const handler = routes[`${method} ${url.pathname}`] + const [status, data] = handler ? handler({ body, url }) : [500, { error: `No mock for ${method} ${url.pathname}` }] + return new Response(status === 204 ? null : JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }), + ) + return calls +} + +export function signedIn(routes: Record = {}): Call[] { + return mockApi({ 'GET /api/auth/me': () => [200, alice], 'GET /api/genres': () => [200, genres], ...routes }) +} + +export function renderApp(url: string) { + const user = userEvent.setup() + render( + + + + + , + ) + return user +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..f56cdd4 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,9 @@ +export const card = 'rounded-lg bg-white p-4 shadow' +export const labelText = 'mb-1 block text-sm font-medium text-slate-700' +export const inputClass = + 'block w-full rounded-md border border-slate-300 px-3 py-2 focus-visible:outline-2 focus-visible:outline-indigo-600 aria-invalid:border-red-600' +const button = + 'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50' +export const primaryButton = `${button} bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600` +export const secondaryButton = `${button} border border-slate-300 bg-white text-slate-800 hover:bg-slate-100 focus-visible:outline-indigo-600` +export const dangerButton = `${button} bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600`