feat: add book detail with progress updates and delete

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
2026-10-02 14:25:17 -05:00
parent f0efa25f23
commit da6c5c2de5
3 changed files with 234 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { Book } from '../api'
import { dune, renderApp, signedIn } from '../test/helpers'
function withProgress(current_page: number): Book {
const status = current_page === 0 ? 'not_started' : current_page === dune.total_pages ? 'finished' : 'reading'
return { ...dune, current_page, status }
}
describe('BookDetail', () => {
it('updates progress and reflects it in the bar', async () => {
const calls = signedIn({
'GET /api/books/7': () => [200, dune],
'PATCH /api/books/7': ({ body }) => [200, withProgress((body as { current_page: number }).current_page)],
})
const user = renderApp('/books/7')
expect(await screen.findByText('142 / 380 (37%)')).toBeInTheDocument()
const input = screen.getByLabelText('Current page')
await user.clear(input)
await user.type(input, '200')
await user.click(screen.getByRole('button', { name: 'Save progress' }))
expect(await screen.findByText('200 / 380 (53%)')).toBeInTheDocument()
expect(calls).toContainEqual({ method: 'PATCH', path: '/api/books/7', body: { current_page: 200 } })
})
it('marks a book finished', async () => {
signedIn({
'GET /api/books/7': () => [200, dune],
'PATCH /api/books/7': () => [200, withProgress(380)],
})
const user = renderApp('/books/7')
await user.click(await screen.findByRole('button', { name: 'Mark finished' }))
expect(await screen.findByText('380 / 380 (100%)')).toBeInTheDocument()
expect(screen.getByText('Finished')).toBeInTheDocument()
})
it('shows the server message when a progress update is rejected', async () => {
signedIn({
'GET /api/books/7': () => [200, dune],
'PATCH /api/books/7': () => [400, { error: 'current_page must be between 0 and 100000', field: 'current_page' }],
})
const user = renderApp('/books/7')
await user.click(await screen.findByRole('button', { name: 'Save progress' }))
// findByText, not findByRole('alert'): once Task 11 mounts the journal, the page may show more than one alert.
expect(await screen.findByText('current_page must be between 0 and 100000')).toHaveAttribute('role', 'alert')
expect(screen.getByLabelText('Current page')).toHaveAttribute('aria-invalid', 'true')
})
it('shows not found for a missing or foreign book', async () => {
signedIn({ 'GET /api/books/999': () => [404, { error: 'Book not found' }] })
renderApp('/books/999')
expect(await screen.findByRole('heading', { name: 'Book not found' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /back to library/i })).toHaveAttribute('href', '/books')
})
it('deletes after confirmation and returns to the list', async () => {
const calls = signedIn({
'GET /api/books/7': () => [200, dune],
'DELETE /api/books/7': () => [204],
'GET /api/books': () => [200, []],
})
const user = renderApp('/books/7')
await user.click(await screen.findByRole('button', { name: 'Delete book' }))
expect(screen.getByRole('dialog')).toHaveTextContent('Delete “Dune”?')
await user.click(screen.getByRole('button', { name: 'Yes, delete' }))
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
await waitFor(() => expect(calls.some((c) => c.method === 'DELETE' && c.path === '/api/books/7')).toBe(true))
})
})