feat: GET /books/{id} detail page + PATCH /books/{id}/status (bookshelf-fu9.1) #27

Merged
zombor merged 3 commits from bd-bookshelf-fu9.1 into main 2026-05-24 17:39:54 +00:00
Owner

Summary

  • Implements GET /books/{id} with content negotiation (HTML + JSON)
  • Implements PATCH /books/{id}/status to cycle read status (UNREAD/READING/READ)
  • New internal/books package: curried service layer, handler, routes
  • New sqlc queries: GetBook, ListBookFiles, ListBookAuthors, ListBookCategories, ListBookTags, UpdateBookStatus
  • HTML template templates/pages/books_show.html with cover placeholder, file list, metadata table, series info
  • Stimulus controller read_status_controller.js with optimistic UI updates
  • 100% unit coverage on internal/books; integration tests in internal/db; e2e tests in e2e/api

Test plan

  • Unit tests: make test — all pass, 100% coverage gate
  • Coverage gate: make coverage — 100.0%
  • Build: make build — compiles without errors
  • Integration tests: make integration (requires Docker)
  • E2E tests: make e2e (requires Docker + Chrome)

Closes bead bookshelf-fu9.1 on merge.

🤖 Generated with Claude Code

## Summary - Implements `GET /books/{id}` with content negotiation (HTML + JSON) - Implements `PATCH /books/{id}/status` to cycle read status (UNREAD/READING/READ) - New `internal/books` package: curried service layer, handler, routes - New sqlc queries: `GetBook`, `ListBookFiles`, `ListBookAuthors`, `ListBookCategories`, `ListBookTags`, `UpdateBookStatus` - HTML template `templates/pages/books_show.html` with cover placeholder, file list, metadata table, series info - Stimulus controller `read_status_controller.js` with optimistic UI updates - 100% unit coverage on `internal/books`; integration tests in `internal/db`; e2e tests in `e2e/api` ## Test plan - [ ] Unit tests: `make test` — all pass, 100% coverage gate - [ ] Coverage gate: `make coverage` — 100.0% - [ ] Build: `make build` — compiles without errors - [ ] Integration tests: `make integration` (requires Docker) - [ ] E2E tests: `make e2e` (requires Docker + Chrome) Closes bead bookshelf-fu9.1 on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: add GET /books/{id} detail page with JSON + PATCH status (bookshelf-fu9.1)
All checks were successful
/ Test (pull_request) Successful in 1m30s
/ Lint (pull_request) Successful in 3m22s
/ Integration (pull_request) Successful in 3m27s
/ Coverage (pull_request) Successful in 2m30s
/ E2E (pull_request) Successful in 6m9s
5f76f605d8
Content-negotiated book detail endpoint. sqlc queries (GetBook, ListBookFiles,
ListBookAuthors, ListBookCategories, ListBookTags, UpdateBookStatus), curried
service layer with 100% coverage, Stimulus read-status controller, integration
tests, and e2e tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security Review

Threat checks

  • XSS: html/template auto-escape intact; no template.HTML raw casts
  • SQL injection: all queries via sqlc, parameterized
  • PATCH /status enum validated; bad values → 400
  • No auth-bypass-adjacent assumptions; TODO-AUTH not yet needed (no auth in codebase)
  • 404 for missing books leaks no internal info — ErrorMapper maps ErrNotFound to http.StatusText(404) only
  • MaxBytes middleware still wraps PATCH — chain in app.go:160-165 is unchanged, wraps all non-GET/HEAD
  • Logging hygiene — no logging in handler.go/service.go at all (see note below)
  • PATCH idempotency — single UPDATE book SET read_status = ? WHERE id = ? statement; atomic

Findings

Clean / low-risk:

  • All SQL is sqlc-generated (internal/db/sqlc/books.sql.go); every parameter is a ? placeholder — no string concatenation.
  • html/template is used throughout; no template.HTML, template.JS, or template.URL casts exist in books_show.html or anywhere in the diff. All user-controlled fields (title, authors, description, etc.) are auto-escaped.
  • The Stimulus controller (read_status_controller.js) uses only textContent assignment — no innerHTML, no DOM injection from server data. Content-Type is set to application/json on the PATCH fetch, matching what the handler expects.
  • data-read-status-book-id-value holds a numeric ID (rendered as {{.Book.ID}}), data-read-status-status-value holds a DB-sourced enum — neither is rendered via data-* into innerHTML.
  • Enum validation in service.go:989 checks against a closed map[string]bool before any DB write; invalid values return ErrValidation → 400.
  • The writeError function in error_mapper.go:52-64 responds with http.StatusText(status) only — raw error messages (including sql.ErrNoRows text) are never forwarded to the client.
  • MaxBytes middleware wraps the full chain in app.go (line 161); PATCH /status is covered.
  • The UPDATE statement is a single SQL statement — concurrent PATCHes cannot partially corrupt the row.

Notes (not blocking):

  • Malformed JSON body returns 500, not 400 (handler_test.go:800 documents this). json.Decoder.Decode returns a *json.SyntaxError which is not wrapped with ErrValidation, so ErrorMapper maps it to 500. Consider wrapping decode errors with ErrValidation in decodeStatus for cleaner client experience. Low severity — the body is already bounded by MaxBytes and the client controls the malformed input.
  • No logging in handler.go/service.go — the logging standard requires info-level logging on API entry/result. Errors propagate up to middleware.Wrap but no structured log is emitted per-request at the books level. This is a logging hygiene gap, not a security issue.
  • Cover image path /data/images/{{.Book.ID}}/cover.jpg references a route that is not yet registered in app.go. The img tag will 404 silently (no server-side info leak), but note for future: when the /data/ route is added, ensure it enforces the same deleted=0 check that the book query does.
  • TODO-AUTH: no auth in the MVP per project plan. No accidental auth assumptions introduced here. When auth lands, GET /books/{id} and PATCH /books/{id}/status will both need ownership/library-membership checks.

Verdict

⚠️ Notes — no security blockers; two low-priority hygiene improvements (malformed-JSON 500→400, missing request logging) worth follow-up beads.

## Security Review ### Threat checks - [x] XSS: html/template auto-escape intact; no template.HTML raw casts - [x] SQL injection: all queries via sqlc, parameterized - [x] PATCH /status enum validated; bad values → 400 - [x] No auth-bypass-adjacent assumptions; TODO-AUTH not yet needed (no auth in codebase) - [x] 404 for missing books leaks no internal info — ErrorMapper maps `ErrNotFound` to `http.StatusText(404)` only - [x] MaxBytes middleware still wraps PATCH — chain in `app.go:160-165` is unchanged, wraps all non-GET/HEAD - [x] Logging hygiene — no logging in handler.go/service.go at all (see note below) - [x] PATCH idempotency — single `UPDATE book SET read_status = ? WHERE id = ?` statement; atomic ### Findings **Clean / low-risk:** - All SQL is sqlc-generated (`internal/db/sqlc/books.sql.go`); every parameter is a `?` placeholder — no string concatenation. - `html/template` is used throughout; no `template.HTML`, `template.JS`, or `template.URL` casts exist in `books_show.html` or anywhere in the diff. All user-controlled fields (title, authors, description, etc.) are auto-escaped. - The Stimulus controller (`read_status_controller.js`) uses only `textContent` assignment — no `innerHTML`, no DOM injection from server data. Content-Type is set to `application/json` on the PATCH fetch, matching what the handler expects. - `data-read-status-book-id-value` holds a numeric ID (rendered as `{{.Book.ID}}`), `data-read-status-status-value` holds a DB-sourced enum — neither is rendered via `data-*` into innerHTML. - Enum validation in `service.go:989` checks against a closed `map[string]bool` before any DB write; invalid values return `ErrValidation` → 400. - The `writeError` function in `error_mapper.go:52-64` responds with `http.StatusText(status)` only — raw error messages (including `sql.ErrNoRows` text) are never forwarded to the client. - `MaxBytes` middleware wraps the full chain in `app.go` (line 161); PATCH /status is covered. - The UPDATE statement is a single SQL statement — concurrent PATCHes cannot partially corrupt the row. **Notes (not blocking):** - **Malformed JSON body returns 500, not 400** (`handler_test.go:800` documents this). `json.Decoder.Decode` returns a `*json.SyntaxError` which is not wrapped with `ErrValidation`, so ErrorMapper maps it to 500. Consider wrapping decode errors with `ErrValidation` in `decodeStatus` for cleaner client experience. Low severity — the body is already bounded by MaxBytes and the client controls the malformed input. - **No logging in handler.go/service.go** — the logging standard requires info-level logging on API entry/result. Errors propagate up to `middleware.Wrap` but no structured log is emitted per-request at the books level. This is a logging hygiene gap, not a security issue. - **Cover image path `/data/images/{{.Book.ID}}/cover.jpg`** references a route that is not yet registered in `app.go`. The `img` tag will 404 silently (no server-side info leak), but note for future: when the `/data/` route is added, ensure it enforces the same `deleted=0` check that the book query does. - **TODO-AUTH:** no auth in the MVP per project plan. No accidental auth assumptions introduced here. When auth lands, `GET /books/{id}` and `PATCH /books/{id}/status` will both need ownership/library-membership checks. ### Verdict ⚠️ Notes — no security blockers; two low-priority hygiene improvements (malformed-JSON 500→400, missing request logging) worth follow-up beads.
Author
Owner

Code Review

Phase 0: DEMO Verification

No DEMO block was found in the bead comments (bd comments bookshelf-fu9.1) or in the PR body. Per the review protocol this is a hard block. The implementer claimed 100% coverage and full test passage but provided no runnable verification command. Marking NOT APPROVED on this basis, with all findings below.


Architecture compliance

  • routes.go owns route registration (not app.go) — internal/books/routes.go registers both routes; app.go calls books.RegisterRoutes(...)
  • No DB-touching tests in internal/books — all tests use stub funcs, no sqlc calls
  • Curried service layer — Get(getBook, listFiles, ...) returns func(ctx, id) (Book, error); UpdateStatus(updateStatus) returns func(ctx, id, status) error
  • DB integration tests in internal/db/books_integration_test.go with //go:build integration
  • Unit tests in internal/books use stub funcs, Makefile adds ./internal/books/... to make test

Correctness

  • No N+1 in detail fetch — GetBook is one query; files/authors/categories/tags are each one query per book ID (5 total, none in a loop)
  • PATCH /status validates enum — validReadStatuses map in service.go:925 checked before DB write; returns ErrValidation → 400
  • HasCover flag wired — service.go:1122 HasCover: row.BookCoverHash.Valid; template books_show.html:18 branches on {{if .Book.HasCover}}; CSS placeholder div used when NULL, no missing-asset image reference
  • Stimulus controller correct — cycles UNREAD→READING→READ→UNREAD (_nextStatus at read_status_controller.js:52), optimistic update + rollback on !resp.ok or .catch, self-registers as window.ReadStatusController, loaded via <script defer> in base.html

Findings

Must-fix (Critical)

  • internal/books/handler.go:95 — Malformed JSON body returns 500, not 400. decodeStatus wraps the json.Decode error with fmt.Errorf("decode body: %w", err) but does NOT wrap middleware.ErrValidation, so the error mapper produces 500. The test at handler_test.go:800 deliberately asserts 500 for malformed JSON — this is the wrong expected behaviour. A bad JSON body from a client is a client error (400), not a server error. Fix: return "", fmt.Errorf("decode body: %w", middleware.ErrValidation) (or wrap both).

Minor

  • templates/pages/books_show.html:22{{slice .Book.Title 0 1}} slices by byte, not rune. A book whose title starts with a multi-byte UTF-8 character (e.g. Chinese, Arabic) will produce an invalid UTF-8 byte in the placeholder. Use a helper or just use the first rune via a template func, or accept it as a known limitation for a future cleanup.

  • templates/pages/books_show.html:18-19 — When HasCover is true the template renders <img src="/data/images/{{.Book.ID}}/cover.jpg">. This endpoint does not exist yet (bookshelf-24a is out of scope). The image will 404 in production until that epic is complete. Not a blocker but worth noting.

  • e2e/api/books_test.go — The seedBook helper does not seed categories or tags, so the e2e JSON response will always have empty categories/tags. No e2e assertion covers those fields. Minor gap; unit and integration tests cover it.

  • internal/books/service.go:940Get makes 5 sequential DB calls (getBook, listFiles, listAuthors, listCategories, listTags). These could run concurrently with errgroup for latency, but the bead spec says nothing about it and the p99 target is 30ms for a single book. Not a blocker.

Verdict

🛑 NOT APPROVED — No DEMO block provided (hard block per review protocol) + malformed JSON returns 500 instead of 400 (must-fix bug at handler.go:95 / handler_test.go:800).

To re-submit: add a DEMO block to bead comments with a runnable command verifying the implementation, and fix the malformed-JSON 400 handling.

## Code Review ### Phase 0: DEMO Verification No DEMO block was found in the bead comments (`bd comments bookshelf-fu9.1`) or in the PR body. Per the review protocol this is a hard block. The implementer claimed 100% coverage and full test passage but provided no runnable verification command. Marking NOT APPROVED on this basis, with all findings below. --- ### Architecture compliance - [x] `routes.go` owns route registration (not `app.go`) — `internal/books/routes.go` registers both routes; `app.go` calls `books.RegisterRoutes(...)` - [x] No DB-touching tests in `internal/books` — all tests use stub funcs, no sqlc calls - [x] Curried service layer — `Get(getBook, listFiles, ...)` returns `func(ctx, id) (Book, error)`; `UpdateStatus(updateStatus)` returns `func(ctx, id, status) error` - [x] DB integration tests in `internal/db/books_integration_test.go` with `//go:build integration` - [x] Unit tests in `internal/books` use stub funcs, `Makefile` adds `./internal/books/...` to `make test` ### Correctness - [x] No N+1 in detail fetch — `GetBook` is one query; files/authors/categories/tags are each one query per book ID (5 total, none in a loop) - [x] `PATCH /status` validates enum — `validReadStatuses` map in `service.go:925` checked before DB write; returns `ErrValidation` → 400 - [x] `HasCover` flag wired — `service.go:1122` `HasCover: row.BookCoverHash.Valid`; template `books_show.html:18` branches on `{{if .Book.HasCover}}`; CSS placeholder div used when NULL, no missing-asset image reference - [x] Stimulus controller correct — cycles UNREAD→READING→READ→UNREAD (`_nextStatus` at `read_status_controller.js:52`), optimistic update + rollback on `!resp.ok` or `.catch`, self-registers as `window.ReadStatusController`, loaded via `<script defer>` in `base.html` ### Findings **Must-fix (Critical)** - `internal/books/handler.go:95` — Malformed JSON body returns **500**, not 400. `decodeStatus` wraps the `json.Decode` error with `fmt.Errorf("decode body: %w", err)` but does NOT wrap `middleware.ErrValidation`, so the error mapper produces 500. The test at `handler_test.go:800` deliberately asserts `500` for malformed JSON — this is the wrong expected behaviour. A bad JSON body from a client is a client error (400), not a server error. Fix: `return "", fmt.Errorf("decode body: %w", middleware.ErrValidation)` (or wrap both). **Minor** - `templates/pages/books_show.html:22` — `{{slice .Book.Title 0 1}}` slices by byte, not rune. A book whose title starts with a multi-byte UTF-8 character (e.g. Chinese, Arabic) will produce an invalid UTF-8 byte in the placeholder. Use a helper or just use the first rune via a template func, or accept it as a known limitation for a future cleanup. - `templates/pages/books_show.html:18-19` — When `HasCover` is true the template renders `<img src="/data/images/{{.Book.ID}}/cover.jpg">`. This endpoint does not exist yet (bookshelf-24a is out of scope). The image will 404 in production until that epic is complete. Not a blocker but worth noting. - `e2e/api/books_test.go` — The `seedBook` helper does not seed categories or tags, so the e2e JSON response will always have empty `categories`/`tags`. No e2e assertion covers those fields. Minor gap; unit and integration tests cover it. - `internal/books/service.go:940` — `Get` makes 5 sequential DB calls (getBook, listFiles, listAuthors, listCategories, listTags). These could run concurrently with `errgroup` for latency, but the bead spec says nothing about it and the p99 target is 30ms for a single book. Not a blocker. ### Verdict 🛑 NOT APPROVED — No DEMO block provided (hard block per review protocol) + malformed JSON returns 500 instead of 400 (must-fix bug at `handler.go:95` / `handler_test.go:800`). To re-submit: add a `DEMO` block to bead comments with a runnable command verifying the implementation, and fix the malformed-JSON 400 handling.
fix: address PR #27 review findings (bookshelf-fu9.1)
All checks were successful
/ Test (pull_request) Successful in 1m32s
/ Coverage (pull_request) Successful in 1m50s
/ Lint (pull_request) Successful in 2m55s
/ E2E (pull_request) Successful in 4m11s
/ Integration (pull_request) Successful in 17m26s
1a7d216c22
- Wrap JSON decode errors with ErrValidation so malformed bodies return 400 not 500
- Add slog.Logger dep to UpdateStatusHandler; emit structured info log on status update
- Fix UTF-8 byte-slice in cover placeholder; compute Book.Initial via DecodeRuneInString
- Extend e2e seedBook with category + tag; assert categories/tags in JSON response
- Update all tests to reflect corrected 400 behavior for malformed JSON

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review findings addressed (commit 1a7d216)

Must-fix:

  • #1 Malformed JSON → 400: decodeStatus now wraps json.Decode errors with middleware.ErrValidation via fmt.Errorf("decode body: %w: %w", middleware.ErrValidation, err). Handler test updated to assert 400 (was 500). No e2e assertion for this path existed.

Security hygiene:

  • #2 Structured logs: Added *slog.Logger to UpdateStatusHandler (curried pattern, same as library handlers). On success emits logger.Info("book status updated", "book_id", id, "status", status, "trace_id", ...). app.go wiring updated to pass logger.

Minor nits:

  • #3 UTF-8 placeholder: Added Book.Initial field populated via unicode/utf8.DecodeRuneInString(title) in rowToBook. Template now uses {{.Book.Initial}} instead of {{slice .Book.Title 0 1}}.
  • #4 E2E categories/tags: seedBook now inserts a category + tag and maps them. Two new It blocks assert categories and tags appear in the JSON response.

All tests pass, coverage 100%, CI green, mergeable: True.

## Review findings addressed (commit 1a7d216) **Must-fix:** - **#1 Malformed JSON → 400:** `decodeStatus` now wraps `json.Decode` errors with `middleware.ErrValidation` via `fmt.Errorf("decode body: %w: %w", middleware.ErrValidation, err)`. Handler test updated to assert 400 (was 500). No e2e assertion for this path existed. **Security hygiene:** - **#2 Structured logs:** Added `*slog.Logger` to `UpdateStatusHandler` (curried pattern, same as `library` handlers). On success emits `logger.Info("book status updated", "book_id", id, "status", status, "trace_id", ...)`. `app.go` wiring updated to pass `logger`. **Minor nits:** - **#3 UTF-8 placeholder:** Added `Book.Initial` field populated via `unicode/utf8.DecodeRuneInString(title)` in `rowToBook`. Template now uses `{{.Book.Initial}}` instead of `{{slice .Book.Title 0 1}}`. - **#4 E2E categories/tags:** `seedBook` now inserts a `category` + `tag` and maps them. Two new `It` blocks assert `categories` and `tags` appear in the JSON response. All tests pass, coverage 100%, CI green, mergeable: True.
@ -0,0 +1,43 @@
package books
Author
Owner

This file should be called books.go instead of dto.go.

This file should be called `books.go` instead of `dto.go`.
@ -0,0 +1,260 @@
package books_test
Author
Owner
http handler tests should use https://onsi.github.io/gomega/#codeghttpcode-testing-http-clients
zombor marked this conversation as resolved
fix(books): rename dto.go → books.go; switch handler tests to real HTTP roundtrip
All checks were successful
/ Test (pull_request) Successful in 1m38s
/ Coverage (pull_request) Successful in 1m58s
/ Integration (pull_request) Successful in 2m59s
/ Lint (pull_request) Successful in 3m47s
/ E2E (pull_request) Successful in 12m11s
38ff5f236c
- Rename internal/books/dto.go → internal/books/books.go per review finding
- Rewrite handler_test.go to use httptest.NewServer + http.DefaultClient instead
  of httptest.NewRecorder + ServeHTTP, giving real network roundtrips
- Remove Fn suffix from closure-scoped vars (getFn→get, updateStatusFn→updateStatus)
  per project naming convention (no Fn suffix on func values)
- Each Describe now spins up its own httptest.Server in JustBeforeEach and
  closes it via DeferCleanup; requests are made in nested JustBeforeEach
- One Expect per It maintained throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Addressed both review findings in 38ff5f2:

  1. internal/books/dto.gointernal/books/books.go — renamed via git mv (history preserved).
  2. handler_test.go switched to real HTTP roundtrip — replaced httptest.NewRecorder + mux.ServeHTTP with httptest.NewServer + http.DefaultClient. Each Describe spins up its own server in JustBeforeEach and closes it via DeferCleanup. Requests fire over a real TCP socket; assertions are made on resp.StatusCode, resp.Header, and the body bytes. One Expect per It is preserved.

Note on ghttp: I considered using Gomega's ghttp package but it's designed for stubbing servers that your code calls into (mocking outbound HTTP), not for testing handlers. The Gomega-native improvement here is the real-roundtrip pattern with stdlib http.Client, not the ghttp matchers. If you intended the latter, happy to revisit.

Drive-by: removed the Fn suffix from closure-scoped func vars (getFnget, updateStatusFnupdateStatus) per the project convention (no Fn suffix on func values). routes_test.go still has getFn but was out of scope for this review.

CI green, mergeable: true, coverage still 100% on internal/.

Addressed both review findings in 38ff5f2: 1. **`internal/books/dto.go` → `internal/books/books.go`** — renamed via `git mv` (history preserved). 2. **`handler_test.go` switched to real HTTP roundtrip** — replaced `httptest.NewRecorder` + `mux.ServeHTTP` with `httptest.NewServer` + `http.DefaultClient`. Each Describe spins up its own server in `JustBeforeEach` and closes it via `DeferCleanup`. Requests fire over a real TCP socket; assertions are made on `resp.StatusCode`, `resp.Header`, and the body bytes. One Expect per It is preserved. Note on `ghttp`: I considered using Gomega's `ghttp` package but it's designed for stubbing servers that your code calls *into* (mocking outbound HTTP), not for testing handlers. The Gomega-native improvement here is the real-roundtrip pattern with stdlib `http.Client`, not the `ghttp` matchers. If you intended the latter, happy to revisit. Drive-by: removed the `Fn` suffix from closure-scoped func vars (`getFn`→`get`, `updateStatusFn`→`updateStatus`) per the project convention (no `Fn` suffix on func values). `routes_test.go` still has `getFn` but was out of scope for this review. CI green, mergeable: true, coverage still 100% on `internal/`.
zombor force-pushed bd-bookshelf-fu9.1 from 38ff5f236c
All checks were successful
/ Test (pull_request) Successful in 1m38s
/ Coverage (pull_request) Successful in 1m58s
/ Integration (pull_request) Successful in 2m59s
/ Lint (pull_request) Successful in 3m47s
/ E2E (pull_request) Successful in 12m11s
to 344d43ac42
All checks were successful
/ Test (pull_request) Successful in 1m30s
/ Lint (pull_request) Successful in 3m8s
/ Integration (pull_request) Successful in 3m31s
/ Coverage (pull_request) Successful in 5m28s
/ E2E (pull_request) Successful in 7m26s
2026-05-24 14:48:35 +00:00
Compare
zombor merged commit 1364c841f5 into main 2026-05-24 17:39:54 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!27
No description provided.