fix: cap request bodies, link login error, add book-form retry, guard double add

Limit request bodies to 1 MiB (413 JSON), wire aria-describedby on the
login form, add Retry and cancellation to the book form load, disable
Add entry while a note POST is in flight, and poll pg_isready in README.

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
2026-10-02 14:36:14 -05:00
parent 35ad0b13e2
commit 2512c67893
9 changed files with 72 additions and 13 deletions
+5 -1
View File
@@ -45,6 +45,8 @@ export function LoginForm() {
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
aria-invalid={error ? true : undefined}
aria-describedby={error ? 'login-error' : undefined}
required
/>
</label>
@@ -57,11 +59,13 @@ export function LoginForm() {
onChange={(e) => setPassword(e.target.value)}
autoComplete={isLogin ? 'current-password' : 'new-password'}
minLength={isLogin ? undefined : 12}
aria-invalid={error ? true : undefined}
aria-describedby={error ? 'login-error' : undefined}
required
/>
</label>
{error && (
<p role="alert" className="text-sm text-red-700">
<p id="login-error" role="alert" className="text-sm text-red-700">
{error}
</p>
)}
+1
View File
@@ -35,6 +35,7 @@ describe('auth flow', () => {
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')
expect(screen.getByLabelText('Password')).toHaveAttribute('aria-describedby', 'login-error')
})
it('registers a new account', async () => {
+11
View File
@@ -62,4 +62,15 @@ describe('BookForm', () => {
body: { title: 'Dune Messiah', author: 'Frank Herbert', genre_id: 5, total_pages: 380, current_page: 142 },
})
})
it('retries a failed load and shows the prefilled form', async () => {
let attempts = 0
signedIn({
'GET /api/books/7': () => (++attempts === 1 ? [500, { error: 'Database unavailable' }] : [200, dune]),
})
const user = renderApp('/books/7/edit')
expect(await screen.findByRole('alert')).toHaveTextContent('Database unavailable')
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByLabelText('Title')).toHaveValue('Dune')
})
})
+21 -9
View File
@@ -19,11 +19,16 @@ export function BookForm() {
const [loadError, setLoadError] = useState<string | null>(null)
const [error, setError] = useState<FormError | null>(null)
const [busy, setBusy] = useState(false)
const [attempt, setAttempt] = useState(0)
useEffect(() => {
if (!editing) return
let cancelled = false
setLoadError(null)
setLoading(true)
api<Book>('GET', `/books/${id}`).then(
(book) => {
if (cancelled) return
setFields({
title: book.title,
author: book.author,
@@ -34,11 +39,15 @@ export function BookForm() {
setLoading(false)
},
(err) => {
if (cancelled) return
setLoadError(errorMessage(err))
setLoading(false)
},
)
}, [editing, id])
return () => {
cancelled = true
}
}, [editing, id, attempt])
function set(name: keyof Fields) {
return (event: { target: { value: string } }) => setFields((prev) => ({ ...prev, [name]: event.target.value }))
@@ -72,13 +81,16 @@ export function BookForm() {
if (loadError) {
return (
<div className={card}>
<p role="alert" className="text-red-700">
{loadError}
</p>
<Link to="/books" className="mt-2 inline-block text-indigo-700 underline">
← Back to library
</Link>
<div role="alert" className={card}>
<p className="text-red-700">{loadError}</p>
<div className="mt-2 flex items-center gap-3">
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</button>
<Link to="/books" className="text-indigo-700 underline">
← Back to library
</Link>
</div>
</div>
)
}
@@ -106,7 +118,7 @@ export function BookForm() {
))}
</select>
</label>
{genresError && <p className="text-sm text-red-700">{`Could not load genres: ${genresError}`}</p>}
{genresError && <p role="alert" className="text-sm text-red-700">{`Could not load genres: ${genresError}`}</p>}
<div className="flex gap-4">
<label className="block">
<span className={labelText}>Total pages</span>
+19 -1
View File
@@ -1,4 +1,4 @@
import { screen, within } from '@testing-library/react'
import { fireEvent, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { Note } from '../api'
import { dune, renderApp, signedIn } from '../test/helpers'
@@ -77,4 +77,22 @@ describe('NotesJournal', () => {
await user.click(screen.getByRole('button', { name: 'Add entry' }))
expect(await screen.findByRole('alert')).toHaveTextContent('body must be at most 10000 characters')
})
it('posts a double-clicked entry only once', async () => {
const calls = signedIn({
'GET /api/books/7': () => [200, dune],
'GET /api/books/7/notes': () => [200, []],
'POST /api/books/7/notes': ({ body }) => [201, { ...older, id: 12, body: (body as { body: string }).body }],
})
const user = renderApp('/books/7')
await screen.findByText('No entries yet.')
await user.type(screen.getByLabelText('New journal entry'), 'Once only')
const add = screen.getByRole('button', { name: 'Add entry' })
// Two clicks before the pending POST settles; user.dblClick would wait for it and mask the bug.
fireEvent.click(add)
fireEvent.click(add)
await screen.findByText('Once only')
expect(calls.filter((c) => c.method === 'POST')).toHaveLength(1)
})
})
+6 -1
View File
@@ -10,6 +10,7 @@ export function NotesJournal({ bookId }: { bookId: number }) {
const [attempt, setAttempt] = useState(0)
const [draft, setDraft] = useState('')
const [addError, setAddError] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
useEffect(() => {
let cancelled = false
@@ -29,6 +30,8 @@ export function NotesJournal({ bookId }: { bookId: number }) {
async function addNote(event: FormEvent) {
event.preventDefault()
if (adding) return
setAdding(true)
setAddError(null)
try {
const note = await api<Note>('POST', `/books/${bookId}/notes`, { body: draft })
@@ -36,6 +39,8 @@ export function NotesJournal({ bookId }: { bookId: number }) {
setDraft('')
} catch (err) {
setAddError(errorMessage(err))
} finally {
setAdding(false)
}
}
@@ -57,7 +62,7 @@ export function NotesJournal({ bookId }: { bookId: number }) {
aria-describedby={addError ? 'add-note-error' : undefined}
/>
</label>
<button type="submit" className={primaryButton}>
<button type="submit" disabled={adding} className={primaryButton}>
Add entry
</button>
{addError && (