Compare commits

..
10 Commits
Author SHA1 Message Date
mroberts 7f5d034a1f feat: auth by default, theme toggle, CoderPad startup, review fixes
- Require a session on every route; public routes opt out with @allow_anonymous
- Split password hashing and pepper loading into passwords.py
- Add a system/light/dark theme toggle backed by light-dark() colors
- Ignore stale 401s from an earlier session, PATCH only changed book fields,
  and block overlapping journal-entry saves
- Add bin/start and CoderPad Vite server settings for the pad's start/restart
- Rewrite README as a mise onboarding guide; expand .gitignore
- Include review-round fixes and tests
2026-10-02 16:49:14 -05:00
mroberts 9d7b0e805c style: drop redundant comments and apply formatting
Frontend now uses double quotes and semicolons. No behaviour change.
2026-10-02 15:06:56 -05:00
mroberts d9b824851a chore: add mise format and lint tasks (ruff + Biome)
Tools are pinned in mise.toml. ruff.toml points first-party detection at backend/, since the frontend's src/auth, src/books and src/notes otherwise get matched as first-party. biome.json uses spaces to match the existing Prettier-style formatting. Also combines a nested with in test_passwords.py (ruff SIM117).
2026-10-02 15:06:56 -05:00
mroberts 0801a69cc0 chore: add mise tasks to run the app locally
mise run dev installs deps, starts Postgres, then runs Flask and Vite together.
2026-10-02 14:46:16 -05:00
mroberts 63c9991421 style: format backend with black
Also drops the session-sliding comment in login_required.
2026-10-02 14:46:16 -05:00
mroberts 2512c67893 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
2026-10-02 14:36:14 -05:00
mroberts 35ad0b13e2 docs: add README with run, test, and design notes
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
2026-10-02 14:30:00 -05:00
mroberts 9e247b6f06 feat: add reading journal to book detail
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
2026-10-02 14:27:48 -05:00
mroberts 18bc74f8f9 feat: add create and edit book form
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
2026-10-02 14:26:30 -05:00
mroberts da6c5c2de5 feat: add book detail with progress updates and delete
Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
2026-10-02 14:25:17 -05:00
53 changed files with 3449 additions and 679 deletions
+25 -1
View File
@@ -1,6 +1,30 @@
# Python
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.mypy_cache/
.ruff_cache/
.pytest_cache/
.coverage
htmlcov/
# Node
node_modules/
dist/
backend/.pepper
coverage/
*.tsbuildinfo
npm-debug.log*
# Secrets
.env
.env.*
!.env.example
backend/.pepper
# Editors and OS
.vscode/
.idea/
.DS_Store
*.swp
+71
View File
@@ -0,0 +1,71 @@
# My Library
A personal book tracker. You sign up, add the books you own, track how far you are in each one, and keep a
reading journal for every book. You can search your library by title or author and filter it by genre. Each
user sees only their own books.
It is a Flask (Python) API with a React (TypeScript) frontend, backed by PostgreSQL.
## What you need
- [mise](https://mise.jdx.dev) – installs the right versions of Python, Node and the other tools for you
- [Docker](https://www.docker.com) – runs the PostgreSQL database
## First-time setup
```bash
mise trust # allow mise to use this project's mise.toml
mise install # install Python, Node, uv, ruff and biome
mise run setup # create the Python virtual environment and install all dependencies
```
## Run the app
```bash
mise run dev
```
This starts the database, the API (port 5000) and the web app. Open http://localhost:5173 and create an
account. Press `Ctrl+C` to stop it.
You can also start the parts on their own: `mise run db`, `mise run dev:api` and `mise run dev:web`.
To stop the database when you are done, run `docker compose down`.
## Run the tests
```bash
mise run test # everything
mise run test:py # backend only
mise run test:ts # frontend only, plus the type check
```
The backend tests use a real PostgreSQL database called `books_test`. It is created for you, so the database
from `mise run db` just needs to be running (the test task starts it).
## Lint and format
```bash
mise run format # fix formatting and import order (writes changes)
mise run lint # check linting, formatting and types without changing files
```
Run `mise run lint` and `mise run test` before you push.
## Change backend dependencies
Edit `backend/requirements.in`, then run:
```bash
mise run lock # rebuild the pinned backend/requirements.txt
mise run setup # install the new versions
```
## Settings
- `DATABASE_URL` – the PostgreSQL connection string. It defaults to the local Docker database.
- `PASSWORD_PEPPER` – a secret of at least 32 characters used when storing passwords. If you do not set it,
a development value is created in `backend/.pepper` and a warning is logged. Always set it outside local
development.
Run `mise tasks` to see every available task.
+35 -18
View File
@@ -1,8 +1,10 @@
"""Application entry point: wires slices, cross-cutting request rules, and JSON error handling."""
import logging
import psycopg2.errors
from flask import Flask, g, jsonify, request
from flask import Flask, Response, g, jsonify, request
from flask.typing import ResponseReturnValue
from werkzeug.exceptions import HTTPException
import auth
@@ -16,53 +18,68 @@ MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def create_app() -> Flask:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024
db.init_app(app)
auth.init_app(app)
app.register_blueprint(books.bp)
app.register_blueprint(notes.bp)
@app.get("/api/health")
def health():
@auth.allow_anonymous
def health() -> ResponseReturnValue:
return {"status": "ok"}
@app.before_request
def require_json_for_mutations():
# CSRF defense (ADR-0002): cross-site requests can only send this content type after a
# CORS preflight, and this API never grants CORS.
def require_json_for_mutations() -> None:
if request.method in MUTATING_METHODS and not request.is_json:
raise ApiError(400, "Request body must be JSON with Content-Type: application/json")
raise ApiError(
400, "Request body must be JSON with Content-Type: application/json"
)
@app.after_request
def log_request(response):
log.info("%s %s -> %s user=%s", request.method, request.path, response.status_code, g.get("user_id"))
def log_request(response: Response) -> Response:
log.info(
"%s %s -> %s user=%s",
request.method,
request.path,
response.status_code,
g.get("user_id"),
)
return response
@app.errorhandler(ApiError)
def handle_api_error(error: ApiError):
def handle_api_error(error: ApiError) -> ResponseReturnValue:
body = {"error": error.message}
if error.field:
body["field"] = error.field
return jsonify(body), error.status
@app.errorhandler(HTTPException)
def handle_http_error(error: HTTPException):
return jsonify(error=error.description), error.code
def handle_http_error(error: HTTPException) -> ResponseReturnValue:
return jsonify(error=error.description), error.code or 500
@app.errorhandler(psycopg2.errors.CheckViolation)
def handle_check_violation(error: psycopg2.errors.CheckViolation):
return jsonify(error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry"), 400
def handle_check_violation(
error: psycopg2.errors.CheckViolation,
) -> ResponseReturnValue:
return (
jsonify(
error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry"
),
400,
)
@app.errorhandler(Exception)
def handle_unexpected(_error: Exception):
def handle_unexpected(_error: Exception) -> ResponseReturnValue:
log.exception("Unhandled error on %s %s", request.method, request.path)
return jsonify(error="Unexpected server error"), 500
return app
app = create_app()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
create_app().run(host="127.0.0.1", port=5000)
+98 -107
View File
@@ -1,111 +1,76 @@
"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes."""
import base64
import functools
"""Auth slice: server-side sessions (ADR-0002) and auth routes."""
import hashlib
import hmac
import logging
import os
import re
import secrets
from collections.abc import Callable
from pathlib import Path
from typing import ParamSpec, TypedDict, TypeVar, cast
import psycopg2.errors
from flask import Blueprint, Flask, current_app, g, jsonify, make_response, request
from flask import (
Blueprint,
Flask,
Response,
current_app,
g,
jsonify,
make_response,
request,
)
from db import query, query_one
from validation import ApiError, json_body, require_utf8
from db import query, query_one, query_row
from passwords import (
dummy_hash,
hash_password,
load_pepper,
needs_rehash,
verify_password,
)
from validation import ApiError, JsonObject, json_body, require_utf8
log = logging.getLogger(__name__)
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
SESSION_COOKIE = "sid"
USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}")
MIN_PASSWORD, MAX_PASSWORD = 12, 1024
INVALID_LOGIN = "Invalid username or password"
P = ParamSpec("P")
R = TypeVar("R")
class UserRow(TypedDict):
id: int
username: str
class UserWithHash(UserRow):
password_hash: str
def init_app(app: Flask) -> None:
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
_dummy_hash() # pay the one-time cost now so the first unknown-user login isn't measurably slower
dummy_hash()
app.before_request(_require_session)
app.register_blueprint(bp)
# --- Passwords -----------------------------------------------------------------
def hash_password(password: str, pepper: bytes, *, salt: bytes | None = None, iterations: int = ITERATIONS) -> str:
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
derived = _derive(password, pepper, salt, iterations)
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
_, iterations, salt, expected = stored.split("$")
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
return hmac.compare_digest(derived, base64.b64decode(expected))
def needs_rehash(stored: str) -> bool:
return int(stored.split("$")[1]) < ITERATIONS
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
def _b64(raw: bytes) -> str:
return base64.b64encode(raw).decode()
@functools.cache
def _dummy_hash() -> str:
# Verified against for unknown usernames so their response time matches a real account.
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
def load_pepper(pepper_file: Path) -> bytes:
from_env = os.environ.get("PASSWORD_PEPPER")
if from_env is not None:
if len(from_env) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return from_env.encode()
try:
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
pass
else:
with os.fdopen(fd, "w") as f:
f.write(secrets.token_hex(32))
log.warning(
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
"from a secrets manager.",
pepper_file,
)
pepper = pepper_file.read_text().strip()
if len(pepper) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
"the file to regenerate it (existing passwords will stop verifying)."
)
return pepper.encode()
# --- Sessions ------------------------------------------------------------------
def login_required(view):
@functools.wraps(view)
def wrapper(*args, **kwargs):
def allow_anonymous(view: Callable[P, R]) -> Callable[P, R]:
view.allow_anonymous = True # type: ignore[attr-defined]
return view
def _require_session() -> None:
view = current_app.view_functions.get(request.endpoint or "")
if view is None or getattr(view, "allow_anonymous", False):
return
token = request.cookies.get(SESSION_COOKIE)
row = None
if token:
# One statement validates and slides the idle window. The created_at predicate enforces
# the 12-hour cap even while expires_at is still in the future (ADR-0002).
row = query_one(
"""
UPDATE sessions
@@ -120,9 +85,6 @@ def login_required(view):
if row is None:
raise ApiError(401, "Not signed in or session expired; log in again")
g.user_id = row["user_id"]
return view(*args, **kwargs)
return wrapper
def _token_hash(token: str) -> bytes:
@@ -130,84 +92,113 @@ def _token_hash(token: str) -> bytes:
def _pepper() -> bytes:
return current_app.extensions["password_pepper"]
pepper: bytes = current_app.extensions["password_pepper"]
return pepper
def _start_session(user: dict, status: int):
def _start_session(user: UserRow, status: int) -> Response:
token = secrets.token_urlsafe(32)
query("DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],))
query(
"DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],)
)
query(
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (%s, %s, now() + interval '30 minutes')",
(_token_hash(token), user["id"]),
)
response = jsonify(id=user["id"], username=user["username"])
response.status_code = status
response.set_cookie(SESSION_COOKIE, token, httponly=True, secure=True, samesite="Lax", path="/api")
response.set_cookie(
SESSION_COOKIE, token, httponly=True, secure=True, samesite="Lax", path="/api"
)
return response
# --- Routes ----------------------------------------------------------------------
def _password(body: dict) -> str:
def _password(body: JsonObject) -> str:
password = body.get("password")
if not isinstance(password, str):
raise ApiError(400, "password is required and must be a string", "password")
if len(password) > MAX_PASSWORD:
raise ApiError(400, f"password must be at most {MAX_PASSWORD} characters", "password")
raise ApiError(
400, f"password must be at most {MAX_PASSWORD} characters", "password"
)
require_utf8(password, "password")
return password
@bp.post("/register")
def register():
@allow_anonymous
def register() -> Response:
body = json_body({"username", "password"})
username = body.get("username")
if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username):
raise ApiError(400, "username must be 3-64 characters: letters, digits, '.', '_' or '-'", "username")
raise ApiError(
400,
"username must be 3-64 characters: letters, digits, '.', '_' or '-'",
"username",
)
password = _password(body)
if len(password) < MIN_PASSWORD:
raise ApiError(400, f"password must be at least {MIN_PASSWORD} characters", "password")
raise ApiError(
400, f"password must be at least {MIN_PASSWORD} characters", "password"
)
try:
user = query_one(
user = query_row(
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username",
(username, hash_password(password, _pepper())),
)
except psycopg2.errors.UniqueViolation:
raise ApiError(409, "Username already taken; choose another", "username") from None
return _start_session(user, 201)
raise ApiError(
409, "Username already taken; choose another", "username"
) from None
return _start_session(cast(UserRow, user), 201)
@bp.post("/login")
def login():
@allow_anonymous
def login() -> Response:
body = json_body({"username", "password"})
password = _password(body)
username = body.get("username")
user = None
if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username):
user = query_one(
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)", (username,)
user = cast(
UserWithHash | None,
query_one(
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
(username,),
),
)
if user is None:
verify_password(password, _dummy_hash(), _pepper())
verify_password(password, dummy_hash(), _pepper())
raise ApiError(401, INVALID_LOGIN)
if not verify_password(password, user["password_hash"], _pepper()):
raise ApiError(401, INVALID_LOGIN)
if needs_rehash(user["password_hash"]):
query("UPDATE users SET password_hash = %s WHERE id = %s", (hash_password(password, _pepper()), user["id"]))
query(
"UPDATE users SET password_hash = %s WHERE id = %s",
(hash_password(password, _pepper()), user["id"]),
)
return _start_session(user, 200)
@bp.post("/logout")
def logout():
@allow_anonymous
def logout() -> Response:
token = request.cookies.get(SESSION_COOKIE)
if token:
query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),))
response = make_response("", 204)
response.delete_cookie(SESSION_COOKIE, path="/api", secure=True, httponly=True, samesite="Lax")
response.delete_cookie(
SESSION_COOKIE, path="/api", secure=True, httponly=True, samesite="Lax"
)
return response
@bp.get("/me")
@login_required
def me():
return jsonify(query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,)))
def me() -> Response:
return jsonify(
query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,))
)
+95 -40
View File
@@ -1,22 +1,26 @@
"""Books slice: CRUD, progress, search/filter, and the genre list."""
import re
from collections.abc import Callable
from datetime import datetime
from typing import TypedDict, cast
import psycopg2.errors
from flask import Blueprint, g, jsonify, request
from flask import Blueprint, Response, g, jsonify, request
from flask.typing import ResponseReturnValue
from auth import login_required
from db import query, query_one
from validation import ApiError, integer, json_body, text
from db import Params, Row, query, query_one, query_row
from validation import ApiError, JsonObject, integer, json_body, text
bp = Blueprint("books", __name__, url_prefix="/api")
MAX_TEXT = 500
MAX_PAGES = 100_000
MAX_GENRE_ID = 32_767 # SMALLINT
MAX_GENRE_ID = 32_767
GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}")
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
VALIDATORS = {
VALIDATORS: dict[str, Callable[[JsonObject], str | int]] = {
"title": lambda body: text(body, "title", MAX_TEXT),
"author": lambda body: text(body, "author", MAX_TEXT),
"genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID),
@@ -24,6 +28,22 @@ VALIDATORS = {
"current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES),
}
class BookFields(TypedDict):
title: str
author: str
genre_id: int
total_pages: int
current_page: int
class BookRow(BookFields):
id: int
genre_name: str
created_at: datetime
updated_at: datetime
BOOK_SELECT = """
SELECT b.id, b.title, b.author, b.genre_id, gn.name AS genre_name,
b.total_pages, b.current_page, b.created_at, b.updated_at
@@ -40,7 +60,7 @@ def reading_status(current_page: int, total_pages: int) -> str:
return "reading"
def to_json(row: dict) -> dict:
def to_json(row: BookRow) -> dict[str, object]:
return {
"id": row["id"],
"title": row["title"],
@@ -55,18 +75,22 @@ def to_json(row: dict) -> dict:
def escape_like(term: str) -> str:
# Backslash is Postgres's default LIKE escape character.
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def owned_book(book_id: int) -> dict:
row = query_one(BOOK_SELECT + " WHERE b.id = %s AND b.user_id = %s", (book_id, g.user_id))
def owned_book(book_id: int, for_update: bool = False) -> BookRow:
row = query_one(
BOOK_SELECT
+ " WHERE b.id = %s AND b.user_id = %s"
+ (" FOR UPDATE OF b" if for_update else ""),
(book_id, g.user_id),
)
if row is None:
raise ApiError(404, "Book not found")
return row
return cast(BookRow, row)
def _check_progress(book: dict, field: str) -> None:
def _check_progress(book: BookFields, field: str) -> None:
if book["current_page"] > book["total_pages"]:
raise ApiError(
400,
@@ -76,47 +100,57 @@ def _check_progress(book: dict, field: str) -> None:
)
def _save(sql: str, params: tuple) -> dict | None:
def _save(sql: str, params: Params) -> Row:
try:
return query_one(sql, params)
return query_row(sql, params)
except psycopg2.errors.ForeignKeyViolation:
raise ApiError(400, "Unknown genre_id; see GET /api/genres", "genre_id") from None
raise ApiError(
400, "Unknown genre_id; see GET /api/genres", "genre_id"
) from None
@bp.get("/genres")
@login_required
def list_genres():
def list_genres() -> Response:
return jsonify(query("SELECT id, name FROM genres ORDER BY name"))
@bp.get("/books")
@login_required
def list_books():
def list_books() -> Response:
sql = BOOK_SELECT + " WHERE b.user_id = %s"
params: list = [g.user_id]
params: list[object] = [g.user_id]
search = request.args.get("q", "").strip()
if search:
if len(search) > MAX_TEXT or "\x00" in search:
raise ApiError(400, f"q must be plain text of at most {MAX_TEXT} characters", "q")
raise ApiError(
400, f"q must be plain text of at most {MAX_TEXT} characters", "q"
)
pattern = f"%{escape_like(search)}%"
sql += " AND (b.title ILIKE %s OR b.author ILIKE %s)"
params += [pattern, pattern]
genre_id = request.args.get("genre_id", "")
if genre_id:
if not GENRE_ID_PATTERN.fullmatch(genre_id) or int(genre_id) > MAX_GENRE_ID:
raise ApiError(400, "genre_id must be an id from GET /api/genres", "genre_id")
raise ApiError(
400, "genre_id must be an id from GET /api/genres", "genre_id"
)
sql += " AND b.genre_id = %s"
params.append(int(genre_id))
sql += " ORDER BY b.updated_at DESC, b.id DESC"
return jsonify([to_json(row) for row in query(sql, tuple(params))])
return jsonify([to_json(cast(BookRow, row)) for row in query(sql, tuple(params))])
@bp.post("/books")
@login_required
def create_book():
def create_book() -> ResponseReturnValue:
body = json_body(FIELDS)
book = {field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
book["current_page"] = VALIDATORS["current_page"](body) if "current_page" in body else 0
book = cast(
BookFields,
{field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
| {
"current_page": (
VALIDATORS["current_page"](body) if "current_page" in body else 0
)
},
)
_check_progress(book, "current_page")
row = _save(
"""
@@ -124,42 +158,63 @@ def create_book():
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(g.user_id, book["title"], book["author"], book["genre_id"], book["total_pages"], book["current_page"]),
(
g.user_id,
book["title"],
book["author"],
book["genre_id"],
book["total_pages"],
book["current_page"],
),
)
return jsonify(to_json(owned_book(row["id"]))), 201
# int(max=...) keeps ids within Postgres INT range: larger values 404 instead of erroring in SQL.
@bp.get("/books/<int(max=2147483647):book_id>")
@login_required
def get_book(book_id: int):
def get_book(book_id: int) -> Response:
return jsonify(to_json(owned_book(book_id)))
@bp.patch("/books/<int(max=2147483647):book_id>")
@login_required
def update_book(book_id: int):
def update_book(book_id: int) -> Response:
body = json_body(FIELDS)
if not body:
raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}")
current = owned_book(book_id)
book = {field: current[field] for field in FIELDS} | {field: VALIDATORS[field](body) for field in body}
current: Row = dict(owned_book(book_id, for_update=True))
book = cast(
BookFields,
{field: current[field] for field in FIELDS}
| {field: VALIDATORS[field](body) for field in body},
)
_check_progress(book, "total_pages" if "total_pages" in body else "current_page")
_save(
"""
UPDATE books
SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now()
WHERE id = %s AND user_id = %s
RETURNING id
""",
(book["title"], book["author"], book["genre_id"], book["total_pages"], book["current_page"],
book_id, g.user_id),
(
book["title"],
book["author"],
book["genre_id"],
book["total_pages"],
book["current_page"],
book_id,
g.user_id,
),
)
return jsonify(to_json(owned_book(book_id)))
@bp.delete("/books/<int(max=2147483647):book_id>")
@login_required
def delete_book(book_id: int):
if query_one("DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id", (book_id, g.user_id)) is None:
def delete_book(book_id: int) -> ResponseReturnValue:
if (
query_one(
"DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id",
(book_id, g.user_id),
)
is None
):
raise ApiError(404, "Book not found")
return "", 204
+46 -15
View File
@@ -1,17 +1,29 @@
"""PostgreSQL access: a connection pool, one connection per request, and two query helpers."""
import os
from pathlib import Path
"""PostgreSQL access: a connection pool, one connection per request, and query helpers."""
import logging
import os
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import psycopg2.extensions
import psycopg2.extras
import psycopg2.pool
from flask import Flask, current_app, g
from flask import Flask, Response, current_app, g
log = logging.getLogger(__name__)
DEFAULT_DATABASE_URL = "postgresql://postgres:[email protected]:5432/postgres"
SCHEMA = Path(__file__).with_name("schema.sql")
Row = dict[str, Any]
Params = tuple[object, ...]
def init_app(app: Flask) -> None:
pool = psycopg2.pool.ThreadedConnectionPool(1, 5, os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL))
pool = psycopg2.pool.ThreadedConnectionPool(
1, 5, os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL)
)
conn = pool.getconn()
try:
with conn, conn.cursor() as cur:
@@ -23,25 +35,34 @@ def init_app(app: Flask) -> None:
app.teardown_appcontext(_release_connection)
def query(sql: str, params: tuple = ()) -> list[dict]:
def query(sql: str, params: Params = ()) -> Sequence[Row]:
with _connection().cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
return cur.fetchall() if cur.description else []
def query_one(sql: str, params: tuple = ()) -> dict | None:
def query_one(sql: str, params: Params = ()) -> Row | None:
rows = query(sql, params)
return rows[0] if rows else None
def _connection():
def query_row(sql: str, params: Params = ()) -> Row:
rows = query(sql, params)
if len(rows) != 1:
raise RuntimeError(
f"Expected exactly one row but got {len(rows)}; the statement must return one row: {sql.strip()}"
)
return rows[0]
def _connection() -> psycopg2.extensions.connection:
if "db" not in g:
g.db = current_app.extensions["db_pool"].getconn()
return g.db
conn: psycopg2.extensions.connection = g.db
return conn
def _finish_transaction(response):
# Commit only successful responses so a 4xx/5xx never leaves partial writes behind.
def _finish_transaction(response: Response) -> Response:
conn = g.get("db")
if conn is not None:
if response.status_code < 400:
@@ -51,8 +72,18 @@ def _finish_transaction(response):
return response
def _release_connection(_exc):
def _release_connection(_exc: BaseException | None) -> None:
conn = g.pop("db", None)
if conn is not None:
conn.rollback() # no-op after commit; discards work if after_request never ran
current_app.extensions["db_pool"].putconn(conn)
if conn is None:
return
pool = current_app.extensions["db_pool"]
try:
conn.rollback()
except psycopg2.Error:
log.warning(
"Discarding a database connection that failed to roll back; the pool will open a new one",
exc_info=True,
)
pool.putconn(conn, close=True)
else:
pool.putconn(conn)
+36 -19
View File
@@ -1,8 +1,12 @@
"""Notes slice: a per-book reading journal. Ownership is always derived through books.user_id."""
from flask import Blueprint, g, jsonify
from auth import login_required
from db import query, query_one
from datetime import datetime
from typing import TypedDict, cast
from flask import Blueprint, Response, g, jsonify
from flask.typing import ResponseReturnValue
from db import Row, query, query_one, query_row
from validation import ApiError, json_body, text
bp = Blueprint("notes", __name__, url_prefix="/api")
@@ -11,24 +15,37 @@ MAX_BODY = 10_000
NOTE_COLUMNS = "n.id, n.book_id, n.body, n.created_at, n.updated_at"
def to_json(row: dict) -> dict:
class NoteRow(TypedDict):
id: int
book_id: int
body: str
created_at: datetime
updated_at: datetime
def to_json(row: Row) -> dict[str, object]:
note = cast(NoteRow, row)
return {
"id": row["id"],
"book_id": row["book_id"],
"body": row["body"],
"created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(),
"id": note["id"],
"book_id": note["book_id"],
"body": note["body"],
"created_at": note["created_at"].isoformat(),
"updated_at": note["updated_at"].isoformat(),
}
def _require_book(book_id: int) -> None:
if query_one("SELECT 1 FROM books WHERE id = %s AND user_id = %s", (book_id, g.user_id)) is None:
if (
query_one(
"SELECT 1 FROM books WHERE id = %s AND user_id = %s", (book_id, g.user_id)
)
is None
):
raise ApiError(404, "Book not found")
@bp.get("/books/<int(max=2147483647):book_id>/notes")
@login_required
def list_notes(book_id: int):
def list_notes(book_id: int) -> Response:
_require_book(book_id)
rows = query(
f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC",
@@ -38,17 +55,18 @@ def list_notes(book_id: int):
@bp.post("/books/<int(max=2147483647):book_id>/notes")
@login_required
def add_note(book_id: int):
def add_note(book_id: int) -> ResponseReturnValue:
_require_book(book_id)
body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one(f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}", (book_id, body))
row = query_row(
f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}",
(book_id, body),
)
return jsonify(to_json(row)), 201
@bp.patch("/notes/<int(max=2147483647):note_id>")
@login_required
def update_note(note_id: int):
def update_note(note_id: int) -> Response:
body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one(
f"""
@@ -66,8 +84,7 @@ def update_note(note_id: int):
@bp.delete("/notes/<int(max=2147483647):note_id>")
@login_required
def delete_note(note_id: int):
def delete_note(note_id: int) -> ResponseReturnValue:
row = query_one(
"DELETE FROM notes n USING books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING n.id",
(note_id, g.user_id),
+83
View File
@@ -0,0 +1,83 @@
"""Password hashing (ADR-0001): PBKDF2-SHA256 over an HMAC pepper, plus loading that pepper."""
import base64
import functools
import hashlib
import hmac
import logging
import os
import secrets
from pathlib import Path
log = logging.getLogger(__name__)
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
def hash_password(
password: str,
pepper: bytes,
*,
salt: bytes | None = None,
iterations: int = ITERATIONS,
) -> str:
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
derived = _derive(password, pepper, salt, iterations)
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
_, iterations, salt, expected = stored.split("$")
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
return hmac.compare_digest(derived, base64.b64decode(expected))
def needs_rehash(stored: str) -> bool:
return int(stored.split("$")[1]) < ITERATIONS
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
def _b64(raw: bytes) -> str:
return base64.b64encode(raw).decode()
@functools.cache
def dummy_hash() -> str:
# Verified against for unknown usernames so their response time matches a real account.
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
def load_pepper(pepper_file: Path) -> bytes:
from_env = os.environ.get("PASSWORD_PEPPER")
if from_env is not None:
if len(from_env) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return from_env.encode()
try:
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
pass
else:
with os.fdopen(fd, "w") as f:
f.write(secrets.token_hex(32))
log.warning(
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
"from a secrets manager.",
pepper_file,
)
pepper = pepper_file.read_text().strip()
if len(pepper) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
"the file to regenerate it (existing passwords will stop verifying)."
)
return pepper.encode()
+2
View File
@@ -0,0 +1,2 @@
Flask>=3.1.3
psycopg2-binary>=2.9.9
+189 -2
View File
@@ -1,2 +1,189 @@
Flask>=3.0.0
psycopg2-binary>=2.9.9
# This file was autogenerated by uv via the following command:
# uv pip compile --universal --generate-hashes backend/requirements.in -o backend/requirements.txt
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via flask
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
# via -r backend/requirements.in
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# flask
# jinja2
# werkzeug
psycopg2-binary==2.9.13 \
--hash=sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528 \
--hash=sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce \
--hash=sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf \
--hash=sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6 \
--hash=sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf \
--hash=sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964 \
--hash=sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5 \
--hash=sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c \
--hash=sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba \
--hash=sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076 \
--hash=sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705 \
--hash=sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e \
--hash=sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed \
--hash=sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24 \
--hash=sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2 \
--hash=sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26 \
--hash=sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798 \
--hash=sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98 \
--hash=sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0 \
--hash=sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165 \
--hash=sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008 \
--hash=sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50 \
--hash=sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29 \
--hash=sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a \
--hash=sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b \
--hash=sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69 \
--hash=sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955 \
--hash=sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720 \
--hash=sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c \
--hash=sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c \
--hash=sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff \
--hash=sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916 \
--hash=sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8 \
--hash=sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3 \
--hash=sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f \
--hash=sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22 \
--hash=sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8 \
--hash=sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6 \
--hash=sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b \
--hash=sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020 \
--hash=sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7 \
--hash=sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2 \
--hash=sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7 \
--hash=sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac \
--hash=sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b \
--hash=sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292 \
--hash=sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2 \
--hash=sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c \
--hash=sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22 \
--hash=sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b \
--hash=sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9 \
--hash=sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2 \
--hash=sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0 \
--hash=sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf \
--hash=sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9 \
--hash=sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1 \
--hash=sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8 \
--hash=sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373 \
--hash=sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60 \
--hash=sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e \
--hash=sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6 \
--hash=sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee \
--hash=sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0 \
--hash=sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8 \
--hash=sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666 \
--hash=sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1 \
--hash=sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389
# via -r backend/requirements.in
werkzeug==3.1.9 \
--hash=sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060 \
--hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab
# via flask
-2
View File
@@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS genres (
name TEXT NOT NULL UNIQUE
);
-- WHERE NOT EXISTS instead of ON CONFLICT: ON CONFLICT consumes a sequence value per row
-- on every startup, which would eventually overflow SMALLSERIAL.
INSERT INTO genres (name)
SELECT seed.name
FROM (VALUES ('Fiction'), ('Non-Fiction'), ('Mystery'), ('Thriller'), ('Science Fiction'),
+11 -5
View File
@@ -1,26 +1,32 @@
"""Point the app at a dedicated test database before any test module imports it."""
import os
import psycopg2
from psycopg2 import sql
ADMIN_URL = os.environ.get("TEST_ADMIN_DATABASE_URL", "postgresql://postgres:[email protected]:5432/postgres")
ADMIN_URL = os.environ.get(
"TEST_ADMIN_DATABASE_URL", "postgresql://postgres:[email protected]:5432/postgres"
)
TEST_DATABASE = "books_test"
def _ensure_test_database() -> None:
conn = psycopg2.connect(ADMIN_URL)
conn.autocommit = True # CREATE DATABASE cannot run inside a transaction
conn.autocommit = True
try:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (TEST_DATABASE,))
cur.execute(
"SELECT 1 FROM pg_database WHERE datname = %s", (TEST_DATABASE,)
)
if cur.fetchone() is None:
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(TEST_DATABASE)))
cur.execute(
sql.SQL("CREATE DATABASE {}").format(sql.Identifier(TEST_DATABASE))
)
finally:
conn.close()
_ensure_test_database()
# Assigned, never defaulted: tests TRUNCATE tables, so they must not inherit a real DATABASE_URL.
os.environ["DATABASE_URL"] = ADMIN_URL.rsplit("/", 1)[0] + "/" + TEST_DATABASE
os.environ["PASSWORD_PEPPER"] = "test-pepper-0123456789abcdef0123456789abcdef"
+9 -3
View File
@@ -3,9 +3,10 @@ import unittest
import psycopg2
from app import app
from app import create_app
HTTPS = "https://localhost" # the session cookie is Secure
HTTPS = "https://localhost"
app = create_app()
def db_execute(sql: str, params: tuple = ()) -> list[tuple]:
@@ -34,7 +35,12 @@ class ApiTestCase(unittest.TestCase):
return (client or self.client).open(path, **kwargs)
def register(self, username="alice", password="correct horse battery", client=None):
response = self.call("POST", "/api/auth/register", {"username": username, "password": password}, client)
response = self.call(
"POST",
"/api/auth/register",
{"username": username, "password": password},
client,
)
self.assertEqual(response.status_code, 201, response.get_json())
return response
+46 -3
View File
@@ -1,6 +1,10 @@
import os
import subprocess
import sys
from pathlib import Path
from tests.support import HTTPS, ApiTestCase, db_execute
import db
from tests.support import HTTPS, ApiTestCase, app, db_execute
SCHEMA = Path(__file__).parent.parent / "schema.sql"
@@ -16,7 +20,9 @@ class AppTests(ApiTestCase):
db_execute(SCHEMA.read_text())
db_execute(SCHEMA.read_text())
self.assertEqual(db_execute("SELECT count(*) FROM genres")[0][0], 16)
self.assertEqual(db_execute("SELECT last_value FROM genres_id_seq")[0][0], before)
self.assertEqual(
db_execute("SELECT last_value FROM genres_id_seq")[0][0], before
)
def test_unknown_route_returns_json_404(self):
response = self.call("GET", "/api/nope")
@@ -24,9 +30,46 @@ class AppTests(ApiTestCase):
self.assertIn("error", response.get_json())
def test_mutation_without_json_content_type_is_rejected(self):
response = self.client.post("/api/health", data="x", content_type="text/plain", base_url=HTTPS)
response = self.client.post(
"/api/health", data="x", content_type="text/plain", base_url=HTTPS
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.get_json(),
{"error": "Request body must be JSON with Content-Type: application/json"},
)
def test_oversized_body_is_rejected_before_parsing(self):
response = self.call(
"POST",
"/api/auth/login",
{"username": "a", "password": "x" * (2 * 1024 * 1024)},
)
self.assertEqual(response.status_code, 413)
self.assertIn("error", response.get_json())
class ConnectionReleaseTests(ApiTestCase):
def test_closed_connection_is_discarded_instead_of_leaking_a_pool_slot(self):
pool = app.extensions["db_pool"]
with self.assertLogs("db", "WARNING"):
for _ in range(pool.maxconn + 1):
with app.app_context():
db.query("SELECT 1")
db._connection().close()
with app.app_context():
self.assertEqual(db.query("SELECT 1 AS one"), [{"one": 1}])
class AppFactoryTests(ApiTestCase):
def test_importing_the_app_module_does_not_connect_to_the_database(self):
result = subprocess.run(
[sys.executable, "-c", "import app"],
cwd=Path(__file__).parent.parent,
env=os.environ | {"DATABASE_URL": "postgresql://[email protected]:9/none"},
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
+97 -18
View File
@@ -1,14 +1,19 @@
import hashlib
import os
import re
from app import app
from auth import hash_password
from tests.support import ApiTestCase, db_execute
from app import create_app
from passwords import hash_password
from tests.support import ApiTestCase, app, db_execute
class AuthTests(ApiTestCase):
def session_token(self, client=None) -> str:
return (client or self.client).get_cookie("sid", domain="localhost", path="/api").value
return (
(client or self.client)
.get_cookie("sid", domain="localhost", path="/api")
.value
)
def test_register_logs_in_and_me_returns_user(self):
response = self.register("Alice")
@@ -25,11 +30,20 @@ class AuthTests(ApiTestCase):
def test_session_token_is_stored_hashed(self):
self.register()
digest = hashlib.sha256(self.session_token().encode()).digest()
self.assertEqual(db_execute("SELECT count(*) FROM sessions WHERE token_hash = %s", (digest,))[0][0], 1)
self.assertEqual(
db_execute(
"SELECT count(*) FROM sessions WHERE token_hash = %s", (digest,)
)[0][0],
1,
)
def test_duplicate_username_is_case_insensitive(self):
self.register("Alice")
response = self.call("POST", "/api/auth/register", {"username": "alice", "password": "another long password"})
response = self.call(
"POST",
"/api/auth/register",
{"username": "alice", "password": "another long password"},
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.get_json()["field"], "username")
@@ -40,7 +54,14 @@ class AuthTests(ApiTestCase):
({"username": "alice", "password": "short"}, "password"),
({"username": "alice", "password": "x" * 1025}, "password"),
({"username": "alice", "password": "\ud800" * 12}, "password"),
({"username": "alice", "password": "long enough password", "is_admin": True}, "is_admin"),
(
{
"username": "alice",
"password": "long enough password",
"is_admin": True,
},
"is_admin",
),
]
for payload, field in cases:
with self.subTest(payload=payload):
@@ -50,34 +71,53 @@ class AuthTests(ApiTestCase):
def test_login_is_case_insensitive_on_username(self):
self.register("Alice")
self.client = app.test_client() # fresh cookie jar: signed out
response = self.call("POST", "/api/auth/login", {"username": "ALICE", "password": "correct horse battery"})
self.client = app.test_client()
response = self.call(
"POST",
"/api/auth/login",
{"username": "ALICE", "password": "correct horse battery"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json(), {"id": 1, "username": "Alice"})
def test_wrong_password_and_unknown_user_are_indistinguishable(self):
self.register()
wrong = self.call("POST", "/api/auth/login", {"username": "alice", "password": "not the password"})
unknown = self.call("POST", "/api/auth/login", {"username": "nobody", "password": "not the password"})
wrong = self.call(
"POST",
"/api/auth/login",
{"username": "alice", "password": "not the password"},
)
unknown = self.call(
"POST",
"/api/auth/login",
{"username": "nobody", "password": "not the password"},
)
self.assertEqual(wrong.status_code, 401)
self.assertEqual(unknown.status_code, 401)
self.assertEqual(wrong.get_json(), unknown.get_json())
self.assertEqual(wrong.get_json(), {"error": "Invalid username or password"})
def test_login_with_malformed_username_is_401_not_500(self):
response = self.call("POST", "/api/auth/login", {"username": "a\x00b", "password": "whatever password"})
response = self.call(
"POST",
"/api/auth/login",
{"username": "a\x00b", "password": "whatever password"},
)
self.assertEqual(response.status_code, 401)
def test_me_without_session_is_401(self):
response = self.call("GET", "/api/auth/me")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.get_json(), {"error": "Not signed in or session expired; log in again"})
self.assertEqual(
response.get_json(),
{"error": "Not signed in or session expired; log in again"},
)
def test_logout_revokes_token_server_side(self):
self.register()
token = self.session_token()
self.assertEqual(self.call("POST", "/api/auth/logout").status_code, 204)
self.client.set_cookie("sid", token, domain="localhost", path="/api") # replay the stolen cookie
self.client.set_cookie("sid", token, domain="localhost", path="/api")
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
def test_idle_session_expires(self):
@@ -87,14 +127,18 @@ class AuthTests(ApiTestCase):
def test_absolute_cap_applies_even_inside_idle_window(self):
self.register()
db_execute("UPDATE sessions SET created_at = now() - interval '12 hours 1 second'")
db_execute(
"UPDATE sessions SET created_at = now() - interval '12 hours 1 second'"
)
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
def test_activity_slides_idle_expiry(self):
self.register()
db_execute("UPDATE sessions SET expires_at = now() + interval '1 minute'")
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 200)
remaining = db_execute("SELECT expires_at - now() > interval '29 minutes' FROM sessions")[0][0]
remaining = db_execute(
"SELECT expires_at - now() > interval '29 minutes' FROM sessions"
)[0][0]
self.assertTrue(remaining)
def test_old_hash_is_upgraded_on_login(self):
@@ -102,6 +146,41 @@ class AuthTests(ApiTestCase):
pepper = os.environ["PASSWORD_PEPPER"].encode()
old = hash_password("correct horse battery", pepper, iterations=1000)
db_execute("UPDATE users SET password_hash = %s", (old,))
response = self.call("POST", "/api/auth/login", {"username": "alice", "password": "correct horse battery"})
response = self.call(
"POST",
"/api/auth/login",
{"username": "alice", "password": "correct horse battery"},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(db_execute("SELECT password_hash FROM users")[0][0].startswith("pbkdf2_sha256$600000$"))
self.assertTrue(
db_execute("SELECT password_hash FROM users")[0][0].startswith(
"pbkdf2_sha256$600000$"
)
)
class AuthByDefaultTests(ApiTestCase):
PUBLIC = frozenset({"health", "auth.register", "auth.login", "auth.logout"})
def test_only_the_expected_endpoints_allow_anonymous_access(self):
public = {
endpoint
for endpoint, view in app.view_functions.items()
if getattr(view, "allow_anonymous", False)
}
self.assertEqual(public, self.PUBLIC)
def test_every_other_route_rejects_anonymous_requests(self):
for rule in app.url_map.iter_rules():
if rule.endpoint in self.PUBLIC or rule.endpoint == "static":
continue
path = re.sub(r"<[^>]+>", "1", rule.rule)
for method in rule.methods - {"HEAD", "OPTIONS"}:
with self.subTest(method=method, path=path):
self.assertEqual(self.call(method, path).status_code, 401)
def test_a_new_route_requires_login_without_any_decorator(self):
fresh = create_app()
fresh.add_url_rule("/api/new-thing", "new_thing", lambda: {"ok": True})
response = self.call("GET", "/api/new-thing", client=fresh.test_client())
self.assertEqual(response.status_code, 401)
+103 -15
View File
@@ -1,4 +1,10 @@
from tests.support import ApiTestCase
import os
import threading
import time
import psycopg2
from tests.support import ApiTestCase, db_execute
class BookTests(ApiTestCase):
@@ -9,9 +15,15 @@ class BookTests(ApiTestCase):
def test_create_returns_full_shape(self):
book = self.create_book()
self.assertEqual(book["title"], "Dune")
self.assertEqual(book["genre"], {"id": self.genre_id("Science Fiction"), "name": "Science Fiction"})
self.assertEqual((book["current_page"], book["total_pages"], book["status"]), (0, 412, "not_started"))
self.assertIn("T", book["created_at"]) # ISO 8601
self.assertEqual(
book["genre"],
{"id": self.genre_id("Science Fiction"), "name": "Science Fiction"},
)
self.assertEqual(
(book["current_page"], book["total_pages"], book["status"]),
(0, 412, "not_started"),
)
self.assertIn("T", book["created_at"])
def test_create_trims_text(self):
book = self.create_book(title=" Dune ")
@@ -19,25 +31,44 @@ class BookTests(ApiTestCase):
def test_progress_updates_status(self):
book = self.create_book()
reading = self.call("PATCH", f"/api/books/{book['id']}", {"current_page": 100}).get_json()
reading = self.call(
"PATCH", f"/api/books/{book['id']}", {"current_page": 100}
).get_json()
self.assertEqual((reading["current_page"], reading["status"]), (100, "reading"))
finished = self.call("PATCH", f"/api/books/{book['id']}", {"current_page": 412}).get_json()
finished = self.call(
"PATCH", f"/api/books/{book['id']}", {"current_page": 412}
).get_json()
self.assertEqual(finished["status"], "finished")
self.assertEqual(self.call("GET", f"/api/books/{book['id']}").get_json()["current_page"], 412)
self.assertEqual(
self.call("GET", f"/api/books/{book['id']}").get_json()["current_page"], 412
)
def test_edit_details(self):
book = self.create_book()
response = self.call(
"PATCH", f"/api/books/{book['id']}",
{"title": "Dune Messiah", "genre_id": self.genre_id("Fantasy"), "total_pages": 256},
"PATCH",
f"/api/books/{book['id']}",
{
"title": "Dune Messiah",
"genre_id": self.genre_id("Fantasy"),
"total_pages": 256,
},
)
self.assertEqual(response.status_code, 200)
updated = response.get_json()
self.assertEqual((updated["title"], updated["genre"]["name"], updated["total_pages"]), ("Dune Messiah", "Fantasy", 256))
self.assertEqual(
(updated["title"], updated["genre"]["name"], updated["total_pages"]),
("Dune Messiah", "Fantasy", 256),
)
self.assertGreater(updated["updated_at"], book["updated_at"])
def test_create_validation(self):
valid = {"title": "Dune", "author": "Frank Herbert", "genre_id": self.genre_id("Fiction"), "total_pages": 412}
valid = {
"title": "Dune",
"author": "Frank Herbert",
"genre_id": self.genre_id("Fiction"),
"total_pages": 412,
}
cases = [
({**valid, "title": " "}, "title"),
({k: v for k, v in valid.items() if k != "author"}, "author"),
@@ -69,13 +100,19 @@ class BookTests(ApiTestCase):
def test_empty_patch_is_rejected(self):
book = self.create_book()
self.assertEqual(self.call("PATCH", f"/api/books/{book['id']}", {}).status_code, 400)
self.assertEqual(
self.call("PATCH", f"/api/books/{book['id']}", {}).status_code, 400
)
def test_delete(self):
book = self.create_book()
self.assertEqual(self.call("DELETE", f"/api/books/{book['id']}").status_code, 204)
self.assertEqual(
self.call("DELETE", f"/api/books/{book['id']}").status_code, 204
)
self.assertEqual(self.call("GET", f"/api/books/{book['id']}").status_code, 404)
self.assertEqual(self.call("DELETE", f"/api/books/{book['id']}").status_code, 404)
self.assertEqual(
self.call("DELETE", f"/api/books/{book['id']}").status_code, 404
)
def test_out_of_range_id_is_404(self):
self.assertEqual(self.call("GET", "/api/books/99999999999").status_code, 404)
@@ -105,10 +142,61 @@ class BookIsolationTests(ApiTestCase):
book = self.create_book()
bob = self.other_user("bob")
path = f"/api/books/{book['id']}"
for method, payload in (("GET", None), ("PATCH", {"title": "Hacked"}), ("DELETE", None)):
for method, payload in (
("GET", None),
("PATCH", {"title": "Hacked"}),
("DELETE", None),
):
with self.subTest(method=method):
response = self.call(method, path, payload, client=bob)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.get_json(), {"error": "Book not found"})
self.assertEqual(self.call("GET", "/api/books", client=bob).get_json(), [])
self.assertEqual(self.call("GET", path).get_json()["title"], "Dune")
class ConcurrentUpdateTests(ApiTestCase):
def setUp(self):
super().setUp()
self.register()
def wait_for_lock_wait(self):
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
waiting = db_execute(
"SELECT count(*) FROM pg_stat_activity"
" WHERE datname = current_database() AND wait_event_type = 'Lock'"
)[0][0]
if waiting:
return
time.sleep(0.02)
self.fail("PATCH never waited on the locked book row")
def test_concurrent_updates_to_different_fields_both_persist(self):
book = self.create_book()
other_session = psycopg2.connect(os.environ["DATABASE_URL"])
result = {}
try:
with other_session.cursor() as cur:
cur.execute(
"UPDATE books SET title = 'Dune Messiah' WHERE id = %s",
(book["id"],),
)
patch = threading.Thread(
target=lambda: result.update(
response=self.call(
"PATCH", f"/api/books/{book['id']}", {"current_page": 100}
)
)
)
patch.start()
self.wait_for_lock_wait()
other_session.commit()
patch.join(timeout=10)
finally:
other_session.close()
self.assertFalse(patch.is_alive(), "PATCH did not finish")
self.assertEqual(result["response"].status_code, 200)
saved = self.call("GET", f"/api/books/{book['id']}").get_json()
self.assertEqual((saved["title"], saved["current_page"]), ("Dune Messiah", 100))
+25 -7
View File
@@ -29,19 +29,30 @@ class NoteTests(ApiTestCase):
def test_delete(self):
note = self.add("Temporary").get_json()
self.assertEqual(self.call("DELETE", f"/api/notes/{note['id']}").status_code, 204)
self.assertEqual(
self.call("DELETE", f"/api/notes/{note['id']}").status_code, 204
)
self.assertEqual(self.call("GET", self.notes_path).get_json(), [])
self.assertEqual(self.call("DELETE", f"/api/notes/{note['id']}").status_code, 404)
self.assertEqual(
self.call("DELETE", f"/api/notes/{note['id']}").status_code, 404
)
def test_validation(self):
for body, status in ((" ", 400), ("x" * 10_001, 400), ("a\x00b", 400)):
with self.subTest(length=len(body)):
self.assertEqual(self.add(body).status_code, status)
self.assertEqual(self.call("POST", self.notes_path, {"body": "ok", "book_id": 2}).status_code, 400)
self.assertEqual(
self.call(
"POST", self.notes_path, {"body": "ok", "book_id": 2}
).status_code,
400,
)
def test_missing_book_and_out_of_range_ids_are_404(self):
self.assertEqual(self.call("GET", "/api/books/999/notes").status_code, 404)
self.assertEqual(self.call("PATCH", "/api/notes/99999999999", {"body": "x"}).status_code, 404)
self.assertEqual(
self.call("PATCH", "/api/notes/99999999999", {"body": "x"}).status_code, 404
)
def test_deleting_book_deletes_its_notes(self):
self.add("Will be gone")
@@ -53,7 +64,9 @@ class NoteIsolationTests(ApiTestCase):
def test_other_user_cannot_read_add_edit_or_delete_notes(self):
self.register("alice")
book = self.create_book()
note = self.call("POST", f"/api/books/{book['id']}/notes", {"body": "Private"}).get_json()
note = self.call(
"POST", f"/api/books/{book['id']}/notes", {"body": "Private"}
).get_json()
bob = self.other_user("bob")
attempts = (
("GET", f"/api/books/{book['id']}/notes", None),
@@ -63,6 +76,11 @@ class NoteIsolationTests(ApiTestCase):
)
for method, path, payload in attempts:
with self.subTest(method=method, path=path):
self.assertEqual(self.call(method, path, payload, client=bob).status_code, 404)
bodies = [n["body"] for n in self.call("GET", f"/api/books/{book['id']}/notes").get_json()]
self.assertEqual(
self.call(method, path, payload, client=bob).status_code, 404
)
bodies = [
n["body"]
for n in self.call("GET", f"/api/books/{book['id']}/notes").get_json()
]
self.assertEqual(bodies, ["Private"])
+19 -8
View File
@@ -5,16 +5,23 @@ import unittest
from pathlib import Path
from unittest import mock
from auth import ITERATIONS, hash_password, load_pepper, needs_rehash, verify_password
from passwords import (
ITERATIONS,
hash_password,
load_pepper,
needs_rehash,
verify_password,
)
PEPPER = b"p" * 32
# Independently computed: HMAC-SHA256(PEPPER, password) -> PBKDF2-SHA256, 1000 iterations, 16 zero-byte salt.
KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE="
class PasswordHashTests(unittest.TestCase):
def test_known_answer(self):
stored = hash_password("correct horse battery staple", PEPPER, salt=bytes(16), iterations=1000)
stored = hash_password(
"correct horse battery staple", PEPPER, salt=bytes(16), iterations=1000
)
self.assertEqual(stored, KNOWN_ANSWER)
def test_round_trip_uses_current_iterations_and_random_salt(self):
@@ -28,7 +35,9 @@ class PasswordHashTests(unittest.TestCase):
self.assertFalse(verify_password("wrong password!!", KNOWN_ANSWER, PEPPER))
def test_wrong_pepper_fails(self):
self.assertFalse(verify_password("correct horse battery staple", KNOWN_ANSWER, b"q" * 32))
self.assertFalse(
verify_password("correct horse battery staple", KNOWN_ANSWER, b"q" * 32)
)
def test_needs_rehash_below_current_iterations(self):
self.assertTrue(needs_rehash(KNOWN_ANSWER))
@@ -37,8 +46,10 @@ class PasswordHashTests(unittest.TestCase):
class PepperTests(unittest.TestCase):
def test_env_pepper_must_be_long_enough(self):
with mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "short"}):
with self.assertRaisesRegex(RuntimeError, "at least 32 characters"):
with (
mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "short"}),
self.assertRaisesRegex(RuntimeError, "at least 32 characters"),
):
load_pepper(Path("/nonexistent"))
def test_env_pepper_is_used_when_set(self):
@@ -49,9 +60,9 @@ class PepperTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ):
os.environ.pop("PASSWORD_PEPPER", None)
pepper_file = Path(tmp) / ".pepper"
with self.assertLogs("auth", level="WARNING"):
with self.assertLogs("passwords", level="WARNING"):
first = load_pepper(pepper_file)
with self.assertLogs("auth", level="WARNING"):
with self.assertLogs("passwords", level="WARNING"):
second = load_pepper(pepper_file)
self.assertEqual(first, second)
self.assertEqual(len(first), 64)
+18 -8
View File
@@ -8,7 +8,9 @@ class SearchTests(ApiTestCase):
self.scifi = self.genre_id("Science Fiction")
self.fantasy = self.genre_id("Fantasy")
self.create_book(title="Dune", author="Frank Herbert", genre_id=self.scifi)
self.create_book(title="The Hobbit", author="J.R.R. Tolkien", genre_id=self.fantasy)
self.create_book(
title="The Hobbit", author="J.R.R. Tolkien", genre_id=self.fantasy
)
self.create_book(title="100% Pure", author="Jane_Doe", genre_id=self.fantasy)
self.create_book(title="1000 Pages", author="Back\\slash", genre_id=self.scifi)
@@ -22,20 +24,26 @@ class SearchTests(ApiTestCase):
self.assertEqual(self.titles("q=tolkien"), ["The Hobbit"])
def test_wildcards_match_literally(self):
self.assertEqual(self.titles("q=100%25"), ["100% Pure"]) # %25 is "%"
self.assertEqual(self.titles("q=e_D"), ["100% Pure"]) # literal "_" in Jane_Doe
self.assertEqual(self.titles("q=k_s"), []) # unescaped "_" would match "Back\\slash"
self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"]) # %5C is "\"
self.assertEqual(self.titles("q=100%25"), ["100% Pure"])
self.assertEqual(self.titles("q=e_D"), ["100% Pure"])
self.assertEqual(self.titles("q=k_s"), [])
self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"])
def test_genre_filter_and_combination(self):
self.assertEqual(self.titles(f"genre_id={self.fantasy}"), ["100% Pure", "The Hobbit"])
self.assertEqual(
self.titles(f"genre_id={self.fantasy}"), ["100% Pure", "The Hobbit"]
)
self.assertEqual(self.titles(f"q=100&genre_id={self.scifi}"), ["1000 Pages"])
def test_blank_query_returns_everything(self):
self.assertEqual(len(self.titles("q=%20%20")), 4)
def test_invalid_parameters(self):
for query, field in (("genre_id=abc", "genre_id"), ("genre_id=99999", "genre_id"), ("q=a%00b", "q")):
for query, field in (
("genre_id=abc", "genre_id"),
("genre_id=99999", "genre_id"),
("q=a%00b", "q"),
):
with self.subTest(query=query):
response = self.call("GET", f"/api/books?{query}")
self.assertEqual(response.status_code, 400)
@@ -43,4 +51,6 @@ class SearchTests(ApiTestCase):
def test_search_never_returns_other_users_books(self):
bob = self.other_user("bob")
self.assertEqual(self.call("GET", "/api/books?q=dune", client=bob).get_json(), [])
self.assertEqual(
self.call("GET", "/api/books?q=dune", client=bob).get_json(), []
)
+13 -5
View File
@@ -1,6 +1,11 @@
"""Request validation shared by every slice. Errors carry a status, a fix-it message, and the field."""
from typing import Any
from flask import request
JsonObject = dict[str, Any]
class ApiError(Exception):
def __init__(self, status: int, message: str, field: str | None = None):
@@ -10,7 +15,7 @@ class ApiError(Exception):
self.field = field
def json_body(allowed: set[str]) -> dict:
def json_body(allowed: set[str]) -> JsonObject:
body = request.get_json(silent=True)
if not isinstance(body, dict):
raise ApiError(400, "Request body must be a JSON object")
@@ -28,10 +33,14 @@ def require_utf8(value: str, field: str) -> None:
try:
value.encode("utf-8")
except UnicodeEncodeError:
raise ApiError(400, f"{field} contains unpaired surrogate characters; send valid UTF-8 text", field) from None
raise ApiError(
400,
f"{field} contains unpaired surrogate characters; send valid UTF-8 text",
field,
) from None
def text(body: dict, field: str, max_len: int) -> str:
def text(body: JsonObject, field: str, max_len: int) -> str:
value = body.get(field)
if not isinstance(value, str) or not value.strip():
raise ApiError(400, f"{field} is required and must be non-blank text", field)
@@ -44,9 +53,8 @@ def text(body: dict, field: str, max_len: int) -> str:
return value
def integer(body: dict, field: str, low: int, high: int) -> int:
def integer(body: JsonObject, field: str, low: int, high: int) -> int:
value = body.get(field)
# bool is a subclass of int in Python; JSON true must not count as 1.
if isinstance(value, bool) or not isinstance(value, int):
raise ApiError(400, f"{field} must be a whole number", field)
if not low <= value <= high:
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -e
cd "$(dirname "$0")/.."
# The pad template's node_modules lacks this app's extra packages (router, Tailwind); a no-op once installed.
npm install --no-audit --no-fund
# Locally the deps live in the mise venv; the pad's system python3 has Flask and psycopg2 preinstalled.
PYTHON=python3
[ -x .venv/bin/python ] && PYTHON=.venv/bin/python
"$PYTHON" backend/app.py &
FLASK_PID=$!
until curl -s -o /dev/null http://localhost:5000/api/health; do
if ! kill -0 "$FLASK_PID" 2>/dev/null; then
echo "Flask exited during startup; fix the error above (often a missing dependency or an unreachable PostgreSQL) and restart." >&2
exit 1
fi
sleep 0.5
done
exec node_modules/.bin/vite
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
"formatter": {
"indentStyle": "space"
},
"linter": {
"rules": {
"correctness": {
"useExhaustiveDependencies": {
"level": "error",
"options": { "reportUnnecessaryDependencies": false }
}
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Library</title>
<script>
try {
const theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
} catch {}
</script>
</head>
<body>
<div id="root"></div>
+78
View File
@@ -1,3 +1,81 @@
[tools]
python = "3.14"
node = "24"
ruff = "0.16.10"
biome = "2.5.15"
uv = "0.12.21"
[tasks.lock]
description = "Re-resolve backend/requirements.in into the pinned, hashed backend/requirements.txt"
run = "uv pip compile --quiet --universal --generate-hashes backend/requirements.in -o backend/requirements.txt"
[tasks.setup]
description = "Create the venv and install backend and frontend dependencies"
run = [
"python -m venv .venv",
".venv/bin/pip install -q --disable-pip-version-check -r backend/requirements.txt",
"npm install --no-fund --no-audit",
]
[tasks.db]
description = "Start local Postgres and wait until it accepts connections"
run = [
"docker compose up -d db",
"until docker compose exec -T db pg_isready -q -U postgres; do sleep 1; done",
]
[tasks."dev:api"]
description = "Flask API on :5000"
depends = ["setup", "db"]
run = ".venv/bin/python backend/app.py"
[tasks."dev:web"]
description = "Vite UI; /api is proxied to Flask"
depends = ["setup"]
run = "npm run dev"
[tasks.dev]
description = "Start the app for local testing (API + UI)"
depends = ["dev:api", "dev:web"]
[tasks."format:py"]
description = "Sort imports and format the backend"
run = ["ruff check --select I --fix backend", "ruff format backend"]
[tasks."format:ts"]
description = "Sort imports and format the frontend"
run = "biome check --write --linter-enabled=false src vite.config.ts"
[tasks.format]
description = "Format and auto-fix Python and TypeScript"
depends = ["format:py", "format:ts"]
[tasks."lint:py"]
description = "Lint, type-check and verify formatting of the backend (no writes)"
run = [
"ruff check backend",
"ruff format --check backend",
"uvx --quiet --with-requirements backend/requirements.txt --with types-psycopg2==2.9.21.20260911 [email protected] --strict backend --exclude backend/tests",
]
[tasks."lint:ts"]
description = "Lint the frontend and verify formatting (no writes)"
run = "biome check src vite.config.ts"
[tasks.lint]
description = "Lint and format-check Python and TypeScript (CI-safe, no writes)"
depends = ["lint:py", "lint:ts"]
[tasks."test:py"]
description = "Backend integration tests against real Postgres (books_test)"
depends = ["setup", "db"]
run = ".venv/bin/python -W error -m unittest discover -s backend/tests -t backend"
[tasks."test:ts"]
description = "Frontend component tests and typecheck"
depends = ["setup"]
run = ["npm test", "npm run typecheck"]
[tasks.test]
description = "Run backend and frontend tests"
depends = ["test:py", "test:ts"]
+20
View File
@@ -8,6 +8,8 @@
"name": "flask-react-template",
"version": "0.0.0",
"dependencies": {
"@fontsource-variable/newsreader": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.18.4"
@@ -990,6 +992,24 @@
}
}
},
"node_modules/@fontsource-variable/newsreader": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/newsreader/-/newsreader-5.3.0.tgz",
"integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/atkinson-hyperlegible": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.3.0.tgz",
"integrity": "sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+2
View File
@@ -9,6 +9,8 @@
"test": "vitest run"
},
"dependencies": {
"@fontsource-variable/newsreader": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.18.4"
+1
View File
@@ -0,0 +1 @@
src = ["backend"]
+69 -22
View File
@@ -1,11 +1,23 @@
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'
import { Fragment, type ReactNode, useState } from "react";
import {
BrowserRouter,
Link,
Navigate,
Outlet,
Route,
Routes,
useParams,
} from "react-router";
import { errorMessage } from "./api";
import { AuthProvider, useAuth } from "./auth/AuthContext";
import { LoginForm } from "./auth/LoginForm";
import { RequireAuth } from "./auth/RequireAuth";
import { BookDetail } from "./books/BookDetail";
import { BookForm } from "./books/BookForm";
import { BookList } from "./books/BookList";
import { AlertIcon, BookIcon } from "./icons";
import { ThemeToggle } from "./ThemeToggle";
import { errorText, secondaryButton } from "./ui";
export function App() {
return (
@@ -14,7 +26,7 @@ export function App() {
<AppRoutes />
</AuthProvider>
</BrowserRouter>
)
);
}
export function AppRoutes() {
@@ -24,43 +36,78 @@ export function AppRoutes() {
<Route element={<RequireAuth />}>
<Route element={<Shell />}>
<Route path="/books" element={<BookList />} />
<Route path="/books/new" element={<BookForm />} />
<Route
path="/books/:id/edit"
element={
<KeyedByBookId>
<BookForm />
</KeyedByBookId>
}
/>
<Route
path="/books/:id"
element={
<KeyedByBookId>
<BookDetail />
</KeyedByBookId>
}
/>
</Route>
</Route>
<Route path="*" element={<Navigate to="/books" replace />} />
</Routes>
)
);
}
function KeyedByBookId({ children }: { children: ReactNode }) {
const { id } = useParams();
return <Fragment key={id}>{children}</Fragment>;
}
function Shell() {
const { user, logout } = useAuth()
const [error, setError] = useState<string | null>(null)
const { user, logout } = useAuth();
const [error, setError] = useState<string | null>(null);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-4xl items-center justify-between p-4">
<Link to="/books" className="text-lg font-semibold">
<div className="min-h-screen">
<header className="border-b border-line">
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-3 px-6 py-3.5">
<Link
to="/books"
className="flex items-center gap-2.5 rounded-lg font-serif text-2xl font-semibold focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent"
>
<span className="text-accent">
<BookIcon />
</span>
My Library
</Link>
<div className="flex items-center gap-3 text-sm">
<span>{user?.username}</span>
<div className="flex items-center gap-3">
<ThemeToggle />
<span className="text-muted">{user?.username}</span>
<button
type="button"
className={secondaryButton}
onClick={() => logout().catch((err) => setError(errorMessage(err)))}
onClick={() =>
logout().catch((err) => setError(errorMessage(err)))
}
>
Log out
</button>
</div>
</div>
{error && (
<p role="alert" className="mx-auto max-w-4xl px-4 pb-2 text-sm text-red-700">
<p
role="alert"
className={`mx-auto max-w-4xl px-6 pb-3 ${errorText}`}
>
<AlertIcon />
{error}
</p>
)}
</header>
<main className="mx-auto max-w-4xl p-4">
<main className="mx-auto max-w-4xl px-6 pt-11 pb-18">
<Outlet />
</main>
</div>
)
);
}
+48
View File
@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeToggle } from "./ThemeToggle";
const root = document.documentElement;
const stored = new Map<string, string>();
beforeEach(() => {
// Vitest's jsdom on Node 24 exposes no localStorage, so give it a minimal one.
vi.stubGlobal("localStorage", {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => stored.set(key, value),
removeItem: (key: string) => stored.delete(key),
});
});
afterEach(() => {
delete root.dataset.theme;
stored.clear();
});
describe("ThemeToggle", () => {
it("cycles system, light, dark and back to following the system", async () => {
render(<ThemeToggle />);
const button = screen.getByRole("button", { name: /^Theme: System/ });
expect(root.dataset.theme).toBeUndefined();
await userEvent.click(button);
expect(button).toHaveAccessibleName("Theme: Light. Switch to Dark");
expect(root.dataset.theme).toBe("light");
expect(localStorage.getItem("theme")).toBe("light");
await userEvent.click(button);
expect(root.dataset.theme).toBe("dark");
expect(localStorage.getItem("theme")).toBe("dark");
await userEvent.click(button);
expect(root.dataset.theme).toBeUndefined();
expect(localStorage.getItem("theme")).toBeNull();
});
it("starts from the theme index.html applied before first paint", () => {
root.dataset.theme = "dark";
render(<ThemeToggle />);
expect(screen.getByRole("button", { name: /^Theme: Dark/ })).toBeVisible();
});
});
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import { MoonIcon, SunIcon, SystemThemeIcon } from "./icons";
const THEMES = {
system: { label: "System", Icon: SystemThemeIcon, next: "light" },
light: { label: "Light", Icon: SunIcon, next: "dark" },
dark: { label: "Dark", Icon: MoonIcon, next: "system" },
} as const;
type Theme = keyof typeof THEMES;
const root = document.documentElement;
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>(
() => (root.dataset.theme as Theme | undefined) ?? "system",
);
useEffect(() => {
if (theme === "system") delete root.dataset.theme;
else root.dataset.theme = theme;
try {
if (theme === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", theme);
} catch {
// Storage can be blocked (private mode); the choice still applies for this visit.
}
}, [theme]);
const { label, Icon, next } = THEMES[theme];
const hint = `Theme: ${label}. Switch to ${THEMES[next].label}`;
return (
<button
type="button"
aria-label={hint}
title={hint}
onClick={() => setTheme(next)}
className="inline-flex size-11 items-center justify-center rounded-lg text-muted hover:bg-line hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<Icon />
</button>
);
}
+62 -33
View File
@@ -1,54 +1,83 @@
export type User = { id: number; username: string }
export type Genre = { id: number; name: string }
export type ReadingStatus = 'not_started' | 'reading' | 'finished'
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 }
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
readonly status: number;
readonly field?: string;
constructor(status: number, message: string, field?: string) {
super(message)
this.status = status
this.field = field
super(message);
this.status = status;
this.field = field;
}
}
let handleUnauthorized: () => void = () => {}
let handleUnauthorized: () => void = () => {};
let authEpoch = 0;
// A 401 only means "signed out" for the session that sent the request; one started before
// a later login, logout or expiry must not end the session that replaced it.
export function startAuthEpoch() {
authEpoch += 1;
}
export function setUnauthorizedHandler(handler: () => void) {
handleUnauthorized = handler
handleUnauthorized = handler;
}
export async function api<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T> {
export async function api<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
const epoch = authEpoch;
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' },
credentials: "same-origin",
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.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)
if (
response.status === 401 &&
!path.startsWith("/auth/") &&
epoch === authEpoch
) {
startAuthEpoch();
handleUnauthorized();
}
return data as T
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'
return error instanceof ApiError
? error.message
: "Could not reach the server; check your connection and try again";
}
+52 -25
View File
@@ -1,42 +1,69 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, setUnauthorizedHandler, type User } from '../api'
import {
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { api, setUnauthorizedHandler, startAuthEpoch, type User } from "../api";
type AuthValue = {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
register: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
}
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthValue | null>(null)
const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const authChanged = useRef(false);
useEffect(() => {
setUnauthorizedHandler(() => setUser(null))
api<User>('GET', '/auth/me')
.then(setUser, () => setUser(null))
.finally(() => setLoading(false))
}, [])
setUnauthorizedHandler(() => setUser(null));
api<User>("GET", "/auth/me")
.then(
(me) => {
if (!authChanged.current) setUser(me);
},
() => {
if (!authChanged.current) setUser(null);
},
)
.finally(() => setLoading(false));
}, []);
function changeUser(next: User | null) {
authChanged.current = true;
startAuthEpoch();
setUser(next);
}
const value: AuthValue = {
user,
loading,
login: async (username, password) => setUser(await api<User>('POST', '/auth/login', { username, password })),
register: async (username, password) => setUser(await api<User>('POST', '/auth/register', { username, password })),
login: async (username, password) =>
changeUser(
await api<User>("POST", "/auth/login", { username, password }),
),
register: async (username, password) =>
changeUser(
await api<User>("POST", "/auth/register", { username, password }),
),
logout: async () => {
await api('POST', '/auth/logout')
setUser(null)
await api("POST", "/auth/logout");
changeUser(null);
},
}
return <AuthContext value={value}>{children}</AuthContext>
};
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth(): AuthValue {
const value = useContext(AuthContext)
if (!value) throw new Error('useAuth must be used inside <AuthProvider>')
return value
const value = useContext(AuthContext);
if (!value) throw new Error("useAuth must be used inside <AuthProvider>");
return value;
}
+66 -35
View File
@@ -1,43 +1,65 @@
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'
import { type SubmitEvent, useState } from "react";
import { type Location, Navigate, useLocation } from "react-router";
import { errorMessage } from "../api";
import { AlertIcon, BookIcon } from "../icons";
import { ThemeToggle } from "../ThemeToggle";
import {
card,
errorText,
inputClass,
labelText,
linkText,
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<string | null>(null)
const [busy, setBusy] = useState(false)
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<string | null>(null);
const [busy, setBusy] = useState(false);
if (user) {
const from = (location.state as { from?: Location } | null)?.from
return <Navigate to={from ? from.pathname + from.search : '/books'} replace />
const from = (location.state as { from?: Location } | null)?.from;
return (
<Navigate to={from ? from.pathname + from.search : "/books"} replace />
);
}
const isLogin = mode === 'login'
const isLogin = mode === "login";
async function submit(event: FormEvent) {
event.preventDefault()
setError(null)
setBusy(true)
async function submit(event: SubmitEvent) {
event.preventDefault();
setError(null);
setBusy(true);
try {
await (isLogin ? login : register)(username, password)
await (isLogin ? login : register)(username, password);
} catch (err) {
setError(errorMessage(err))
setError(errorMessage(err));
} finally {
setBusy(false)
setBusy(false);
}
}
return (
<div className="min-h-screen bg-slate-50 px-4 pt-16">
<main className={`mx-auto max-w-sm ${card}`}>
<h1 className="mb-4 text-2xl font-semibold">{isLogin ? 'Sign in' : 'Create account'}</h1>
<form onSubmit={submit} className="space-y-4">
<div className="min-h-screen px-4 pt-4 pb-16">
<div className="mb-8 flex justify-end">
<ThemeToggle />
</div>
<p className="mb-6 flex items-center justify-center gap-2.5 font-serif text-3xl font-semibold">
<span className="text-accent">
<BookIcon />
</span>
My Library
</p>
<main className={`mx-auto max-w-sm ${card} sm:p-8`}>
<h1 className="mb-5 font-serif text-3xl font-medium">
{isLogin ? "Sign in" : "Create account"}
</h1>
<form onSubmit={submit} className="space-y-5">
<label className="block">
<span className={labelText}>Username</span>
<input
@@ -45,6 +67,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>
@@ -55,31 +79,38 @@ export function LoginForm() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={isLogin ? 'current-password' : 'new-password'}
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={errorText}>
<AlertIcon />
{error}
</p>
)}
<button type="submit" disabled={busy} className={`${primaryButton} w-full`}>
{isLogin ? 'Sign in' : 'Create account'}
<button
type="submit"
disabled={busy}
className={`${primaryButton} w-full`}
>
{isLogin ? "Sign in" : "Create account"}
</button>
</form>
<button
type="button"
className="mt-4 text-sm text-indigo-700 underline"
className={`mt-5 min-h-11 ${linkText}`}
onClick={() => {
setMode(isLogin ? 'register' : 'login')
setError(null)
setMode(isLogin ? "register" : "login");
setError(null);
}}
>
{isLogin ? 'Need an account? Register' : 'Have an account? Sign in'}
{isLogin ? "Need an account? Register" : "Have an account? Sign in"}
</button>
</main>
</div>
)
);
}
+7 -7
View File
@@ -1,10 +1,10 @@
import { Navigate, Outlet, useLocation } from 'react-router'
import { useAuth } from './AuthContext'
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuth } from "./AuthContext";
export function RequireAuth() {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) return <p className="p-6 text-slate-500">Loading…</p>
if (!user) return <Navigate to="/login" replace state={{ from: location }} />
return <Outlet />
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <p className="p-6 text-slate-500">Loading…</p>;
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
return <Outlet />;
}
+162 -58
View File
@@ -1,69 +1,173 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { alice, dune, genres, mockApi, renderApp, signedIn } from '../test/helpers'
import { act, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
alice,
dune,
genres,
held,
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 () => {
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')
"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' }))
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(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' },
})
})
method: "POST",
path: "/api/auth/login",
body: { username: "alice", password: "correct horse battery" },
});
});
it('shows the server error for a bad login', async () => {
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')
})
"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",
);
expect(screen.getByLabelText("Password")).toHaveAttribute(
"aria-describedby",
"login-error",
);
});
it('registers a new account', async () => {
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)
})
"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("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)
})
})
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);
});
});
describe("initial session check", () => {
it("does not sign out a user who logged in while /auth/me was pending", async () => {
const me = held();
mockApi({
"GET /api/auth/me": () => me.reply,
"POST /api/auth/login": () => [200, alice],
"GET /api/genres": () => [200, genres],
"GET /api/books": () => [200, []],
});
const user = renderApp("/login");
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" }));
await waitFor(() =>
expect(
screen.queryByRole("heading", { name: "Sign in" }),
).not.toBeInTheDocument(),
);
me.release([
401,
{ error: "Not signed in or session expired; log in again" },
]);
expect(
await screen.findByText("No books yet. Add your first one."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
});
});
describe("session expiry", () => {
it("keeps a fresh login when an earlier session's request returns a late 401", async () => {
const expiredGenres = held();
let genreRequests = 0;
let bookRequests = 0;
mockApi({
"GET /api/auth/me": () => [200, alice],
"POST /api/auth/login": () => [200, alice],
"GET /api/genres": () =>
++genreRequests === 1 ? expiredGenres.reply : [200, genres],
"GET /api/books": () =>
++bookRequests === 1 ? [401, { error: "Session expired" }] : [200, []],
});
const user = renderApp("/books");
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" }));
await screen.findByText("No books yet. Add your first one.");
await act(async () => {
expiredGenres.release([401, { error: "Previous session expired" }]);
});
expect(
screen.queryByRole("heading", { name: "Sign in" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
});
});
+187
View File
@@ -0,0 +1,187 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Link, MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { AppRoutes } from "../App";
import type { Book } from "../api";
import { AuthProvider } from "../auth/AuthContext";
import { dune, held, 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("Page 142 of 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("Page 200 of 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("Page 380 of 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" }),
);
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),
);
});
});
describe("BookDetail progress saves", () => {
it("allows one progress save at a time so a late response cannot revert newer progress", async () => {
const first = held();
const calls = signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, []],
"PATCH /api/books/7": () => first.reply,
});
const user = renderApp("/books/7");
const input = await screen.findByLabelText("Current page");
await user.clear(input);
await user.type(input, "10");
await user.click(screen.getByRole("button", { name: "Save progress" }));
expect(
screen.getByRole("button", { name: "Save progress" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Mark finished" }),
).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Mark finished" }));
expect(calls.filter((c) => c.method === "PATCH")).toHaveLength(1);
first.release([200, withProgress(10)]);
expect(await screen.findByText("Page 10 of 380 · 3%")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save progress" })).toBeEnabled();
});
});
describe("BookDetail navigation", () => {
it("does not keep the previous book actionable while the next one loads", async () => {
const next = held();
signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, []],
"GET /api/books/8": () => next.reply,
"GET /api/books/8/notes": () => [200, []],
});
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/books/7"]}>
<AuthProvider>
<AppRoutes />
<Link to="/books/8">Next book</Link>
</AuthProvider>
</MemoryRouter>,
);
await screen.findByRole("heading", { name: "Dune", level: 1 });
await user.click(screen.getByRole("link", { name: "Next book" }));
expect(
screen.queryByRole("heading", { name: "Dune" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Save progress" }),
).not.toBeInTheDocument();
next.release([200, { ...dune, id: 8, title: "Dune Messiah" }]);
expect(
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }),
).toBeInTheDocument();
});
});
+221
View File
@@ -0,0 +1,221 @@
import { type SubmitEvent, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { ApiError, api, type Book, errorMessage } from "../api";
import { AlertIcon, TrashIcon } from "../icons";
import { NotesJournal } from "../notes/NotesJournal";
import {
card,
dangerButton,
errorText,
inputClass,
labelText,
linkText,
pageTitle,
primaryButton,
secondaryButton,
} from "../ui";
import { ProgressBar, StatusBadge } from "./Progress";
export function BookDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [book, setBook] = useState<Book | null>(null);
const [loadError, setLoadError] = useState<{
notFound: boolean;
message: string;
} | null>(null);
const [attempt, setAttempt] = useState(0);
const [page, setPage] = useState("");
const [progressError, setProgressError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoadError(null);
api<Book>("GET", `/books/${id}`).then(
(found) => {
if (cancelled) return;
setBook(found);
setPage(String(found.current_page));
},
(err) => {
if (!cancelled)
setLoadError({
notFound: err instanceof ApiError && err.status === 404,
message: errorMessage(err),
});
},
);
return () => {
cancelled = true;
};
}, [id, attempt]);
if (loadError?.notFound) {
return (
<div className={card}>
<h1 className={pageTitle}>Book not found</h1>
<Link to="/books" className={`mt-3 inline-block ${linkText}`}>
← Back to library
</Link>
</div>
);
}
if (loadError) {
return (
<div role="alert" className="flex flex-wrap items-center gap-3">
<span className={errorText}>
<AlertIcon />
{loadError.message}
</span>
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry
</button>
</div>
);
}
if (!book) return <p className="text-muted">Loading…</p>;
const current = book;
async function saveProgress(currentPage: number) {
if (saving) return;
setSaving(true);
setProgressError(null);
try {
const updated = await api<Book>("PATCH", `/books/${current.id}`, {
current_page: currentPage,
});
setBook(updated);
setPage(String(updated.current_page));
} catch (err) {
setProgressError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function deleteBook() {
setDeleteError(null);
try {
await api("DELETE", `/books/${current.id}`);
navigate("/books", { replace: true });
} catch (err) {
setDeleteError(errorMessage(err));
}
}
function submitProgress(event: SubmitEvent) {
event.preventDefault();
void saveProgress(Number(page));
}
return (
<article className="space-y-7">
<Link
to="/books"
className={`inline-flex min-h-11 items-center ${linkText}`}
>
← Back to library
</Link>
<header className={`${card} space-y-5 sm:p-7`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-serif text-4xl leading-tight font-medium">
{book.title}
</h1>
<p className="mt-1 text-lg text-muted">{`${book.author} · ${book.genre.name}`}</p>
</div>
<StatusBadge status={book.status} />
</div>
<ProgressBar book={book} />
<form
onSubmit={submitProgress}
className="flex flex-wrap items-end gap-3"
>
<label>
<span className={labelText}>Current page</span>
<input
type="number"
className={`${inputClass} w-32`}
min={0}
max={book.total_pages}
value={page}
onChange={(e) => setPage(e.target.value)}
required
aria-invalid={progressError ? true : undefined}
aria-describedby={progressError ? "progress-error" : undefined}
/>
</label>
<button type="submit" disabled={saving} className={primaryButton}>
Save progress
</button>
<button
type="button"
className={secondaryButton}
disabled={saving || book.current_page === book.total_pages}
onClick={() => void saveProgress(book.total_pages)}
>
Mark finished
</button>
</form>
{progressError && (
<p id="progress-error" role="alert" className={errorText}>
<AlertIcon />
{progressError}
</p>
)}
<div className="flex flex-wrap gap-3 border-t border-line pt-5">
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
Edit
</Link>
<button
type="button"
className={dangerButton}
onClick={() => setConfirmingDelete(true)}
>
<TrashIcon />
Delete book
</button>
</div>
{confirmingDelete && (
<dialog
open
aria-labelledby="delete-prompt"
className="relative rounded-xl border-2 border-danger bg-surface p-6 text-ink"
>
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
className={dangerButton}
onClick={() => void deleteBook()}
>
Yes, delete
</button>
<button
type="button"
className={secondaryButton}
onClick={() => setConfirmingDelete(false)}
>
Cancel
</button>
</div>
{deleteError && (
<p role="alert" className={`mt-3 ${errorText}`}>
<AlertIcon />
{deleteError}
</p>
)}
</dialog>
)}
</header>
<NotesJournal bookId={book.id} />
</article>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { dune, renderApp, signedIn } from "../test/helpers";
const fresh = { ...dune, current_page: 0, status: "not_started" as const };
describe("BookForm", () => {
it("adds a book and opens it", async () => {
const calls = signedIn({
"POST /api/books": () => [201, fresh],
"GET /api/books/7": () => [200, fresh],
});
const user = renderApp("/books/new");
await screen.findByRole("option", { name: "Science Fiction" });
await user.type(screen.getByLabelText("Title"), "Dune");
await user.type(screen.getByLabelText("Author"), "Frank Herbert");
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
await user.type(screen.getByLabelText("Total pages"), "380");
await user.click(screen.getByRole("button", { name: "Add book" }));
expect(
await screen.findByRole("heading", { name: "Dune", level: 1 }),
).toBeInTheDocument();
expect(calls).toContainEqual({
method: "POST",
path: "/api/books",
body: {
title: "Dune",
author: "Frank Herbert",
genre_id: 5,
total_pages: 380,
current_page: 0,
},
});
});
it("shows a server validation error against the field", async () => {
signedIn({
"POST /api/books": () => [
400,
{ error: "Unknown genre_id; see GET /api/genres", field: "genre_id" },
],
});
const user = renderApp("/books/new");
await screen.findByRole("option", { name: "Science Fiction" });
await user.type(screen.getByLabelText("Title"), "Dune");
await user.type(screen.getByLabelText("Author"), "Frank Herbert");
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
await user.type(screen.getByLabelText("Total pages"), "380");
await user.click(screen.getByRole("button", { name: "Add book" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Unknown genre_id",
);
expect(screen.getByLabelText("Genre")).toHaveAttribute(
"aria-invalid",
"true",
);
});
it("edits an existing book", async () => {
let saved = dune;
const calls = signedIn({
"GET /api/books/7": () => [200, saved],
"PATCH /api/books/7": ({ body }) => {
saved = { ...saved, ...(body as object) };
return [200, saved];
},
});
const user = renderApp("/books/7/edit");
const title = await screen.findByDisplayValue("Dune");
await user.clear(title);
await user.type(title, "Dune Messiah");
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }),
).toBeInTheDocument();
expect(calls).toContainEqual({
method: "PATCH",
path: "/api/books/7",
body: { title: "Dune Messiah" },
});
});
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");
});
it("saving without changes returns to the book without a request", async () => {
const calls = signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, []],
});
const user = renderApp("/books/7/edit");
await screen.findByDisplayValue("Dune");
await user.click(screen.getByRole("button", { name: "Save changes" }));
await screen.findByRole("heading", { name: "Dune", level: 1 });
expect(calls.some((call) => call.method === "PATCH")).toBe(false);
});
it("preserves progress saved in another session when only the title changes", async () => {
let saved = { ...dune };
signedIn({
"GET /api/books/7": () => [200, { ...saved }],
"GET /api/books/7/notes": () => [200, []],
"PATCH /api/books/7": ({ body }) => {
saved = { ...saved, ...(body as object) };
return [200, saved];
},
});
const user = renderApp("/books/7/edit");
const title = await screen.findByDisplayValue("Dune");
await screen.findByRole("option", { name: "Science Fiction" });
// Another session saves progress after this form loads its snapshot.
saved = { ...saved, current_page: 300 };
await user.clear(title);
await user.type(title, "Dune Messiah");
await user.click(screen.getByRole("button", { name: "Save changes" }));
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 });
expect(screen.getByRole("progressbar")).toHaveAttribute("value", "300");
});
});
+244
View File
@@ -0,0 +1,244 @@
import { type SubmitEvent, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { ApiError, api, type Book, errorMessage } from "../api";
import { AlertIcon } from "../icons";
import {
card,
errorText,
inputClass,
labelText,
linkText,
pageTitle,
primaryButton,
secondaryButton,
} from "../ui";
import { useGenres } from "./useGenres";
type Fields = {
title: string;
author: string;
genre_id: string;
total_pages: string;
current_page: string;
};
type FormError = { message: string; field?: string };
const EMPTY: Fields = {
title: "",
author: "",
genre_id: "",
total_pages: "",
current_page: "0",
};
export function BookForm() {
const { id } = useParams();
const editing = id !== undefined;
const navigate = useNavigate();
const { genres, error: genresError } = useGenres();
const [fields, setFields] = useState<Fields>(EMPTY);
const [loaded, setLoaded] = useState<Fields>(EMPTY);
const [loading, setLoading] = useState(editing);
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;
const current = {
title: book.title,
author: book.author,
genre_id: String(book.genre.id),
total_pages: String(book.total_pages),
current_page: String(book.current_page),
};
setFields(current);
setLoaded(current);
setLoading(false);
},
(err) => {
if (cancelled) return;
setLoadError(errorMessage(err));
setLoading(false);
},
);
return () => {
cancelled = true;
};
}, [editing, id, attempt]);
function set(name: keyof Fields) {
return (event: { target: { value: string } }) =>
setFields((prev) => ({ ...prev, [name]: event.target.value }));
}
function invalid(name: keyof Fields) {
return error?.field === name
? { "aria-invalid": true, "aria-describedby": "form-error" }
: {};
}
async function submit(event: SubmitEvent) {
event.preventDefault();
const payload = {
title: fields.title,
author: fields.author,
genre_id: Number(fields.genre_id),
total_pages: Number(fields.total_pages),
current_page: Number(fields.current_page),
};
// An edit sends only what the user changed, so it cannot overwrite fields
// (such as progress) saved elsewhere after this form loaded.
const changes = Object.fromEntries(
Object.entries(payload).filter(
([name]) =>
fields[name as keyof Fields] !== loaded[name as keyof Fields],
),
);
if (editing && Object.keys(changes).length === 0) {
navigate(`/books/${id}`);
return;
}
setError(null);
setBusy(true);
try {
const saved = editing
? await api<Book>("PATCH", `/books/${id}`, changes)
: await api<Book>("POST", "/books", payload);
navigate(`/books/${saved.id}`);
} catch (err) {
setError({
message: errorMessage(err),
field: err instanceof ApiError ? err.field : undefined,
});
setBusy(false);
}
}
if (loadError) {
return (
<div role="alert" className={card}>
<p className={errorText}>
<AlertIcon />
{loadError}
</p>
<div className="mt-3 flex flex-wrap items-center gap-4">
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry
</button>
<Link to="/books" className={linkText}>
← Back to library
</Link>
</div>
</div>
);
}
if (loading) return <p className="text-muted">Loading…</p>;
return (
<form onSubmit={submit} className={`mx-auto max-w-xl space-y-5 ${card}`}>
<h1 className={pageTitle}>{editing ? "Edit book" : "Add a book"}</h1>
<label className="block">
<span className={labelText}>Title</span>
<input
className={inputClass}
value={fields.title}
onChange={set("title")}
maxLength={500}
required
{...invalid("title")}
/>
</label>
<label className="block">
<span className={labelText}>Author</span>
<input
className={inputClass}
value={fields.author}
onChange={set("author")}
maxLength={500}
required
{...invalid("author")}
/>
</label>
<label className="block">
<span className={labelText}>Genre</span>
<select
className={inputClass}
value={fields.genre_id}
onChange={set("genre_id")}
required
{...invalid("genre_id")}
>
<option value="">Choose a genre</option>
{genres.map((g) => (
<option key={g.id} value={String(g.id)}>
{g.name}
</option>
))}
</select>
</label>
{genresError && (
<p role="alert" className={errorText}>
<AlertIcon />
{`Could not load genres: ${genresError}`}
</p>
)}
<div className="flex flex-wrap gap-4">
<label className="block min-w-36 flex-1">
<span className={labelText}>Total pages</span>
<input
type="number"
className={inputClass}
min={1}
max={100000}
value={fields.total_pages}
onChange={set("total_pages")}
required
{...invalid("total_pages")}
/>
</label>
<label className="block min-w-36 flex-1">
<span className={labelText}>Current page</span>
<input
type="number"
className={inputClass}
min={0}
max={fields.total_pages || undefined}
value={fields.current_page}
onChange={set("current_page")}
required
{...invalid("current_page")}
/>
</label>
</div>
{error && (
<p id="form-error" role="alert" className={errorText}>
<AlertIcon />
{error.message}
</p>
)}
<div className="flex flex-wrap gap-3 pt-1">
<button type="submit" disabled={busy} className={primaryButton}>
{editing ? "Save changes" : "Add book"}
</button>
<Link
to={editing ? `/books/${id}` : "/books"}
className={secondaryButton}
>
Cancel
</Link>
</div>
</form>
);
}
+80 -52
View File
@@ -1,58 +1,86 @@
import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { dune, renderApp, signedIn } from '../test/helpers'
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')
})
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("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("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("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("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("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()
})
})
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();
});
});
+108 -66
View File
@@ -1,73 +1,106 @@
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'
import { useCallback, useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { api, type Book, errorMessage } from "../api";
import { AlertIcon, PlusIcon, SearchIcon } from "../icons";
import {
errorText,
inputClass,
labelText,
pageTitle,
primaryButton,
secondaryButton,
} from "../ui";
import { ProgressBar, StatusBadge } from "./Progress";
import { useGenres } from "./useGenres";
const SEARCH_DEBOUNCE_MS = 250
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<Book[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [attempt, setAttempt] = useState(0)
const { genres, error: genresError } = useGenres()
const [params, setParams] = useSearchParams();
const q = params.get("q") ?? "";
const genre = params.get("genre") ?? "";
const [search, setSearch] = useState(q);
const [books, setBooks] = useState<Book[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const { genres, error: genresError } = useGenres();
function updateParam(key: string, value: string) {
const updateParam = useCallback(
(key: string, value: string) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev)
if (value) next.set(key, value)
else next.delete(key)
return next
const next = new URLSearchParams(prev);
if (value) next.set(key, value);
else next.delete(key);
return next;
},
{ replace: true },
)
}
),
[setParams],
);
// Keep the input in sync when the URL changes from outside (back/forward navigation).
useEffect(() => setSearch(q), [q])
useEffect(() => setSearch(q), [q]);
useEffect(() => {
if (search === q) return
const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [search, q])
if (search === q) return;
const timer = setTimeout(
() => updateParam("q", search),
SEARCH_DEBOUNCE_MS,
);
return () => clearTimeout(timer);
}, [search, q, updateParam]);
useEffect(() => {
let cancelled = false
const query = new URLSearchParams()
if (q.trim()) query.set('q', q.trim())
if (genre) query.set('genre_id', genre)
api<Book[]>('GET', `/books?${query}`).then(
let cancelled = false;
const query = new URLSearchParams();
if (q.trim()) query.set("q", q.trim());
if (genre) query.set("genre_id", genre);
api<Book[]>("GET", `/books?${query}`).then(
(found) => {
if (cancelled) return
setBooks(found)
setError(null)
if (cancelled) return;
setBooks(found);
setError(null);
},
(err) => {
if (!cancelled) setError(errorMessage(err))
if (!cancelled) setError(errorMessage(err));
},
)
);
return () => {
cancelled = true
}
}, [q, genre, attempt])
cancelled = true;
};
}, [q, genre, attempt]);
return (
<section>
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="min-w-48 grow">
<section className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<h1 className={pageTitle}>Your library</h1>
<Link to="/books/new" className={primaryButton}>
<PlusIcon />
Add book
</Link>
</div>
<div className="flex flex-wrap items-end gap-3">
<label className="min-w-0 flex-[1_1_280px]">
<span className={labelText}>Search title or author</span>
<input type="search" className={inputClass} value={search} onChange={(e) => setSearch(e.target.value)} />
<span className="relative block">
<span className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted">
<SearchIcon />
</span>
<input
type="search"
className={`${inputClass} pl-11`}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</span>
</label>
<label>
<label className="flex-[0_1_220px]">
<span className={labelText}>Genre</span>
<select className={inputClass} value={genre} onChange={(e) => updateParam('genre', e.target.value)}>
<select
className={inputClass}
value={genre}
onChange={(e) => updateParam("genre", e.target.value)}
>
<option value="">All genres</option>
{genres.map((g) => (
<option key={g.id} value={String(g.id)}>
@@ -76,47 +109,56 @@ export function BookList() {
))}
</select>
</label>
<Link to="/books/new" className={primaryButton}>
Add book
</Link>
</div>
{genresError && (
<p role="alert" className="mb-3 text-sm text-red-700">
<p role="alert" className={errorText}>
<AlertIcon />
{`Could not load genres: ${genresError}`}
</p>
)}
{error ? (
<div role="alert" className="flex items-center gap-3 text-red-700">
<span>{error}</span>
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
<div role="alert" className="flex flex-wrap items-center gap-3">
<span className={errorText}>
<AlertIcon />
{error}
</span>
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry
</button>
</div>
) : books === null ? (
<p className="text-slate-500">Loading…</p>
<p className="text-muted">Loading…</p>
) : books.length === 0 ? (
<p className="text-slate-600">
{q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'}
<p className="text-muted">
{q.trim() || genre
? "No matches. Try a different search or genre."
: "No books yet. Add your first one."}
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
<ul className="overflow-hidden rounded-xl border border-line bg-surface">
{books.map((book) => (
<li key={book.id}>
<li key={book.id} className="border-b border-line last:border-b-0">
<Link
to={`/books/${book.id}`}
className={`block ${card} hover:ring-2 hover:ring-indigo-300 focus-visible:outline-2 focus-visible:outline-indigo-600`}
className="flex flex-wrap items-center gap-x-6 gap-y-3 px-5 py-4 hover:bg-line/50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
>
<div className="flex items-start justify-between gap-2">
<h2 className="font-semibold">{book.title}</h2>
<StatusBadge status={book.status} />
<div className="min-w-0 flex-[1_1_260px]">
<h2 className="font-serif text-xl leading-snug font-semibold">
{book.title}
</h2>
<p className="text-sm text-muted">{`${book.author} · ${book.genre.name}`}</p>
</div>
<p className="text-sm text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
<ProgressBar book={book} />
<StatusBadge status={book.status} />
<ProgressBar book={book} compact />
</Link>
</li>
))}
</ul>
)}
</section>
)
);
}
+16
View File
@@ -0,0 +1,16 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { ReadingStatus } from "../api";
import { StatusBadge } from "./Progress";
describe("StatusBadge", () => {
it.each<[ReadingStatus, string]>([
["not_started", "Not started"],
["reading", "Reading"],
["finished", "Finished"],
])("shows %s as a text label with a decorative icon", (status, label) => {
render(<StatusBadge status={status} />);
const badge = screen.getByText(label);
expect(badge.querySelector("svg[aria-hidden='true']")).not.toBeNull();
});
});
+46 -14
View File
@@ -1,30 +1,62 @@
import type { Book, ReadingStatus } from '../api'
import type { ReactNode } from "react";
import type { Book, ReadingStatus } from "../api";
import { FinishedIcon, NotStartedIcon, ReadingIcon } from "../icons";
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
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' },
}
const STATUS: Record<
ReadingStatus,
{ label: string; style: string; icon: ReactNode }
> = {
not_started: {
label: "Not started",
style: "bg-idle-bg text-idle-fg",
icon: <NotStartedIcon />,
},
reading: {
label: "Reading",
style: "bg-reading-bg text-reading-fg",
icon: <ReadingIcon />,
},
finished: {
label: "Finished",
style: "bg-finished-bg text-finished-fg",
icon: <FinishedIcon />,
},
};
export function StatusBadge({ status }: { status: ReadingStatus }) {
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-sm font-bold whitespace-nowrap ${STATUS[status].style}`}
>
{STATUS[status].icon}
{STATUS[status].label}
</span>
)
);
}
export function ProgressBar({ book }: { book: Book }) {
const percent = Math.round((book.current_page / book.total_pages) * 100)
export function ProgressBar({
book,
compact = false,
}: {
book: Book;
compact?: boolean;
}) {
const percent = Math.round((book.current_page / book.total_pages) * 100);
return (
<div className="mt-3">
<div className={compact ? "flex flex-[0_1_200px] items-center gap-3" : ""}>
<progress
className="h-2 w-full accent-indigo-600"
className={`block w-full appearance-none overflow-hidden rounded-full bg-track ${compact ? "h-1.5 flex-1" : "h-2"} [&::-moz-progress-bar]:bg-accent [&::-webkit-progress-bar]:bg-track [&::-webkit-progress-value]:bg-accent`}
value={book.current_page}
max={book.total_pages}
aria-label={`Reading progress for ${book.title}`}
/>
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
<p
className={`text-sm text-muted tabular-nums ${compact ? "min-w-10 text-right" : "mt-1.5"}`}
>
{compact
? `${percent}%`
: `Page ${book.current_page} of ${book.total_pages} · ${percent}%`}
</p>
</div>
)
);
}
+9 -7
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import { api, errorMessage, type Genre } from '../api'
import { useEffect, useState } from "react";
import { api, errorMessage, type Genre } from "../api";
export function useGenres(): { genres: Genre[]; error: string | null } {
const [genres, setGenres] = useState<Genre[]>([])
const [error, setError] = useState<string | null>(null)
const [genres, setGenres] = useState<Genre[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api<Genre[]>('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err)))
}, [])
return { genres, error }
api<Genre[]>("GET", "/genres").then(setGenres, (err) =>
setError(errorMessage(err)),
);
}, []);
return { genres, error };
}
+93
View File
@@ -0,0 +1,93 @@
import type { ReactNode } from "react";
function Icon({ size = 18, children }: { size?: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0"
>
{children}
</svg>
);
}
export const BookIcon = () => (
<Icon size={26}>
<path d="M12 6.5C10 5 7.5 4.5 4 4.5v13c3.5 0 6 .5 8 2 2-1.5 4.5-2 8-2v-13c-3.5 0-6 .5-8 2Z" />
<path d="M12 6.5v13" />
</Icon>
);
export const PlusIcon = () => (
<Icon>
<path d="M12 5v14M5 12h14" />
</Icon>
);
export const AlertIcon = () => (
<Icon>
<circle cx="12" cy="12" r="9" />
<path d="M12 7.5v5.5M12 16.5v.01" />
</Icon>
);
export const TrashIcon = () => (
<Icon>
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" />
</Icon>
);
export const SearchIcon = () => (
<Icon>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4 4" />
</Icon>
);
export const NotStartedIcon = () => (
<Icon size={14}>
<circle cx="12" cy="12" r="9" strokeDasharray="3 3" />
</Icon>
);
export const ReadingIcon = () => (
<Icon size={14}>
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none" />
</Icon>
);
export const FinishedIcon = () => (
<Icon size={14}>
<circle cx="12" cy="12" r="9" />
<path d="m8 12.5 2.5 2.5L16 9.5" />
</Icon>
);
export const SystemThemeIcon = () => (
<Icon>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</Icon>
);
export const SunIcon = () => (
<Icon>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</Icon>
);
export const MoonIcon = () => (
<Icon>
<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
</Icon>
);
+57
View File
@@ -1 +1,58 @@
@import "tailwindcss";
:root {
color-scheme: light dark;
--bg: light-dark(#f7f3ec, #181512);
--surface: light-dark(#fffdf8, #231f1b);
--field: light-dark(#fffdf8, #1e1a17);
--line: light-dark(#e4dccd, #3a332c);
--control: light-dark(#8c7f6b, #7d7266);
--ink: light-dark(#2b2620, #eee7dd);
--muted: light-dark(#5e5548, #b9ae9f);
--accent: light-dark(#2f5d50, #e3a857);
--accent-hover: light-dark(#21443a, #edbb73);
--on-accent: light-dark(#ffffff, #1a1612);
--danger: light-dark(#a3341f, #f29c86);
--track: light-dark(#e4dccd, #3a332c);
--idle-bg: light-dark(#ece6db, #2e2924);
--idle-fg: light-dark(#4a4237, #cfc5b8);
--reading-bg: light-dark(#f6e6cf, #3d2a12);
--reading-fg: light-dark(#7a4310, #f2c17e);
--finished-bg: light-dark(#dce7f3, #1d3047);
--finished-fg: light-dark(#234b78, #a9cdf2);
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-field: var(--field);
--color-line: var(--line);
--color-control: var(--control);
--color-ink: var(--ink);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-on-accent: var(--on-accent);
--color-danger: var(--danger);
--color-track: var(--track);
--color-idle-bg: var(--idle-bg);
--color-idle-fg: var(--idle-fg);
--color-reading-bg: var(--reading-bg);
--color-reading-fg: var(--reading-fg);
--color-finished-bg: var(--finished-bg);
--color-finished-fg: var(--finished-fg);
--font-sans: "Atkinson Hyperlegible", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Newsreader Variable", Georgia, serif;
}
body {
@apply bg-bg font-sans text-ink;
}
+16 -6
View File
@@ -1,10 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "@fontsource/atkinson-hyperlegible/400.css";
import "@fontsource/atkinson-hyperlegible/700.css";
import "@fontsource-variable/newsreader";
import "./index.css";
createRoot(document.getElementById('root')!).render(
const root = document.getElementById("root");
if (!root) {
throw new Error(
'index.html has no element with id="root", so the app has nowhere to mount',
);
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
);
+195
View File
@@ -0,0 +1,195 @@
import { act, fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Note } from "../api";
import { dune, held, renderApp, signedIn } from "../test/helpers";
const older: Note = {
id: 11,
book_id: 7,
body: "Started reading",
created_at: "2026-10-01T10:00:00+00:00",
updated_at: "2026-10-01T10:00:00+00:00",
};
describe("NotesJournal", () => {
it("lists entries and adds a new one at the top", async () => {
const calls = signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, [older]],
"POST /api/books/7/notes": ({ body }) => [
201,
{
...older,
id: 12,
body: (body as { body: string }).body,
created_at: "2026-10-02T10:00:00+00:00",
updated_at: "2026-10-02T10:00:00+00:00",
},
],
});
const user = renderApp("/books/7");
expect(await screen.findByText("Started reading")).toBeInTheDocument();
await user.type(
screen.getByLabelText("New journal entry"),
"Loved chapter 3",
);
await user.click(screen.getByRole("button", { name: "Add entry" }));
await screen.findByText("Loved chapter 3");
const items = screen.getAllByRole("listitem");
expect(items[0]).toHaveTextContent("Loved chapter 3");
expect(items[1]).toHaveTextContent("Started reading");
expect(screen.getByLabelText("New journal entry")).toHaveValue("");
expect(calls).toContainEqual({
method: "POST",
path: "/api/books/7/notes",
body: { body: "Loved chapter 3" },
});
});
it("edits an entry and marks it edited", async () => {
signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, [older]],
"PATCH /api/notes/11": ({ body }) => [
200,
{
...older,
body: (body as { body: string }).body,
updated_at: "2026-10-02T10:00:00+00:00",
},
],
});
const user = renderApp("/books/7");
const item = (await screen.findByText("Started reading")).closest("li");
if (!item) throw new Error("Note text is not rendered inside a list item");
await user.click(within(item).getByRole("button", { name: "Edit entry" }));
const editor = within(item).getByLabelText("Edit entry");
await user.clear(editor);
await user.type(editor, "Finished part one");
await user.click(within(item).getByRole("button", { name: "Save" }));
expect(
await within(item).findByText("Finished part one"),
).toBeInTheDocument();
expect(within(item).getByText(/edited/)).toBeInTheDocument();
});
it("deletes an entry", async () => {
signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, [older]],
"DELETE /api/notes/11": () => [204],
});
const user = renderApp("/books/7");
await user.click(
await screen.findByRole("button", { name: "Delete entry" }),
);
expect(await screen.findByText("No entries yet.")).toBeInTheDocument();
});
it("shows the server error when adding fails", async () => {
signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, []],
"POST /api/books/7/notes": () => [
400,
{ error: "body must be at most 10000 characters", field: "body" },
],
});
const user = renderApp("/books/7");
await user.type(await screen.findByLabelText("New journal entry"), "x");
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" });
fireEvent.click(add);
fireEvent.click(add);
await screen.findByText("Once only");
expect(calls.filter((c) => c.method === "POST")).toHaveLength(1);
});
});
describe("NotesJournal request races", () => {
it("does not allow adding an entry until the journal has loaded", async () => {
const notes = held();
const calls = signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => notes.reply,
});
const user = renderApp("/books/7");
await user.type(await screen.findByLabelText("New journal entry"), "Early");
expect(screen.getByRole("button", { name: "Add entry" })).toBeDisabled();
notes.release([200, [older]]);
await screen.findByText("Started reading");
expect(screen.getByRole("button", { name: "Add entry" })).toBeEnabled();
expect(calls.some((c) => c.method === "POST")).toBe(false);
});
it("does not let typing during a save be lost when the save completes", async () => {
const saved = held();
signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, []],
"POST /api/books/7/notes": () => saved.reply,
});
const user = renderApp("/books/7");
const draft = await screen.findByLabelText("New journal entry");
await screen.findByText("No entries yet.");
await user.type(draft, "First thought");
await user.click(screen.getByRole("button", { name: "Add entry" }));
await user.type(draft, " and more");
expect(draft).toHaveValue("First thought");
saved.release([201, { ...older, id: 12, body: "First thought" }]);
expect(await screen.findByText("First thought")).toBeInTheDocument();
expect(draft).toHaveValue("");
});
it("prevents overlapping saves of an existing entry", async () => {
const saved = held();
signedIn({
"GET /api/books/7": () => [200, dune],
"GET /api/books/7/notes": () => [200, [older]],
"PATCH /api/notes/11": () => saved.reply,
});
const user = renderApp("/books/7");
await user.click(await screen.findByRole("button", { name: "Edit entry" }));
const editor = screen.getByLabelText("Edit entry");
await user.clear(editor);
await user.type(editor, "First edit");
const save = screen.getByRole("button", { name: "Save" });
await user.click(save);
try {
expect(save).toBeDisabled();
await user.type(editor, " and more");
expect(editor).toHaveValue("First edit");
} finally {
await act(async () => {
saved.release([200, { ...older, body: "First edit" }]);
});
}
expect(await screen.findByText("First edit")).toBeInTheDocument();
});
});
+245
View File
@@ -0,0 +1,245 @@
import { type SubmitEvent, useEffect, useState } from "react";
import { api, errorMessage, type Note } from "../api";
import { AlertIcon, TrashIcon } from "../icons";
import {
card,
dangerButton,
errorText,
inputClass,
labelText,
primaryButton,
secondaryButton,
} from "../ui";
const MAX_NOTE = 10000;
export function NotesJournal({ bookId }: { bookId: number }) {
const [notes, setNotes] = useState<Note[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
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;
setLoadError(null);
api<Note[]>("GET", `/books/${bookId}/notes`).then(
(found) => {
if (!cancelled) setNotes(found);
},
(err) => {
if (!cancelled) setLoadError(errorMessage(err));
},
);
return () => {
cancelled = true;
};
}, [bookId, attempt]);
async function addNote(event: SubmitEvent) {
event.preventDefault();
if (adding || notes === null) return;
setAdding(true);
setAddError(null);
try {
const note = await api<Note>("POST", `/books/${bookId}/notes`, {
body: draft,
});
setNotes((prev) => [note, ...(prev ?? [])]);
setDraft("");
} catch (err) {
setAddError(errorMessage(err));
} finally {
setAdding(false);
}
}
return (
<section aria-labelledby="journal-heading" className="space-y-4">
<h2 id="journal-heading" className="font-serif text-3xl font-medium">
Reading journal
</h2>
<form onSubmit={addNote} className="space-y-3">
<label className="block">
<span className={labelText}>New journal entry</span>
<textarea
className={inputClass}
rows={3}
maxLength={MAX_NOTE}
value={draft}
onChange={(e) => setDraft(e.target.value)}
readOnly={adding}
required
aria-describedby={addError ? "add-note-error" : undefined}
/>
</label>
<button
type="submit"
disabled={adding || notes === null}
className={primaryButton}
>
Add entry
</button>
{addError && (
<p id="add-note-error" role="alert" className={errorText}>
<AlertIcon />
{addError}
</p>
)}
</form>
{loadError ? (
<div role="alert" className="flex flex-wrap items-center gap-3">
<span className={errorText}>
<AlertIcon />
{loadError}
</span>
<button
type="button"
className={secondaryButton}
onClick={() => setAttempt((n) => n + 1)}
>
Retry
</button>
</div>
) : notes === null ? (
<p className="text-muted">Loading…</p>
) : notes.length === 0 ? (
<p className="text-muted">No entries yet.</p>
) : (
<ol className="space-y-3">
{notes.map((note) => (
<NoteItem
key={note.id}
note={note}
onSaved={(saved) =>
setNotes(
(prev) =>
prev?.map((n) => (n.id === saved.id ? saved : n)) ?? null,
)
}
onDeleted={() =>
setNotes(
(prev) => prev?.filter((n) => n.id !== note.id) ?? null,
)
}
/>
))}
</ol>
)}
</section>
);
}
function NoteItem({
note,
onSaved,
onDeleted,
}: {
note: Note;
onSaved: (note: Note) => void;
onDeleted: () => void;
}) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(note.body);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const errorId = `note-${note.id}-error`;
async function save(event: SubmitEvent) {
event.preventDefault();
if (saving) return;
setSaving(true);
setError(null);
try {
onSaved(await api<Note>("PATCH", `/notes/${note.id}`, { body: text }));
setEditing(false);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function remove() {
setError(null);
try {
await api("DELETE", `/notes/${note.id}`);
onDeleted();
} catch (err) {
setError(errorMessage(err));
}
}
return (
<li className={`${card} px-6 py-5`}>
<p className="text-sm text-muted">
<time dateTime={note.created_at}>
{new Date(note.created_at).toLocaleString()}
</time>
{note.updated_at !== note.created_at && " · edited"}
</p>
{editing ? (
<form onSubmit={save} className="mt-2 space-y-3">
<label className="block">
<span className="sr-only">Edit entry</span>
<textarea
className={inputClass}
rows={3}
maxLength={MAX_NOTE}
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={saving}
required
aria-describedby={error ? errorId : undefined}
/>
</label>
<div className="flex gap-2">
<button type="submit" disabled={saving} className={primaryButton}>
Save
</button>
<button
type="button"
className={secondaryButton}
onClick={() => {
setEditing(false);
setText(note.body);
}}
>
Cancel
</button>
</div>
</form>
) : (
<>
<p className="mt-2 font-serif text-lg leading-relaxed whitespace-pre-wrap">
{note.body}
</p>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
className={secondaryButton}
onClick={() => setEditing(true)}
>
Edit entry
</button>
<button
type="button"
className={dangerButton}
onClick={() => void remove()}
>
<TrashIcon />
Delete entry
</button>
</div>
</>
)}
{error && (
<p id={errorId} role="alert" className={`mt-3 ${errorText}`}>
<AlertIcon />
{error}
</p>
)}
</li>
);
}
+52 -38
View File
@@ -1,65 +1,79 @@
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'
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { vi } from "vitest";
import { AppRoutes } from "../App";
import type { Book, Genre, User } from "../api";
import { AuthProvider } from "../auth/AuthContext";
export const alice: User = { id: 1, username: 'alice' }
export const alice: User = { id: 1, username: "alice" };
export const genres: Genre[] = [
{ id: 3, name: 'Fantasy' },
{ id: 5, name: 'Science Fiction' },
]
{ 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' },
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',
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 | Promise<Reply>;
export type Call = { method: string; path: string; body: unknown };
export function held() {
let release!: (reply: Reply) => void;
const reply = new Promise<Reply>((resolve) => {
release = resolve;
});
return { reply, release };
}
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<string, Handler>): Call[] {
const calls: Call[] = []
const calls: Call[] = [];
vi.stubGlobal(
'fetch',
"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}` }]
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
? await 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' },
})
headers: { "Content-Type": "application/json" },
});
}),
)
return calls
);
return calls;
}
export function signedIn(routes: Record<string, Handler> = {}): Call[] {
return mockApi({ 'GET /api/auth/me': () => [200, alice], 'GET /api/genres': () => [200, genres], ...routes })
return mockApi({
"GET /api/auth/me": () => [200, alice],
"GET /api/genres": () => [200, genres],
...routes,
});
}
export function renderApp(url: string) {
const user = userEvent.setup()
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={[url]}>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</MemoryRouter>,
)
return user
);
return user;
}
+4 -5
View File
@@ -1,6 +1,5 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// Vitest globals are off, so Testing Library cannot register its own cleanup.
afterEach(() => cleanup())
afterEach(() => cleanup());
+12 -7
View File
@@ -1,9 +1,14 @@
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 card = "rounded-xl border border-line bg-surface p-6";
export const pageTitle = "font-serif text-4xl font-medium tracking-tight";
export const labelText = "mb-1.5 block text-sm font-bold";
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'
"block min-h-11 w-full rounded-lg border border-control bg-field px-3.5 py-2 text-ink placeholder:text-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent aria-invalid:border-2 aria-invalid:border-danger";
export const linkText =
"font-bold text-accent underline underline-offset-3 hover:text-accent-hover";
export const errorText =
"flex items-center gap-2 text-sm font-bold text-danger";
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`
"inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-5 font-bold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50";
export const primaryButton = `${button} bg-accent text-on-accent hover:bg-accent-hover`;
export const secondaryButton = `${button} border border-control text-ink hover:bg-line`;
export const dangerButton = `${button} border border-danger text-danger hover:bg-danger/10`;
+24 -7
View File
@@ -1,14 +1,31 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { createLogger, defineConfig } from "vite";
// Set only inside a CoderPad pad, whose preview proxies port 3000 and the HMR websocket.
const padHost = process.env.PROJECTS_BASE_HOST;
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { proxy: { '/api': 'http://127.0.0.1:5000' } },
server: {
proxy: { "/api": { target: "http://127.0.0.1:5000", changeOrigin: true } },
...(padHost
? {
host: "0.0.0.0",
port: 3000,
hmr: { clientPort: Number(process.env.WEBSOCKET_PORT) },
allowedHosts: [`.${padHost}`],
}
: {}),
},
...(padHost
? { customLogger: createLogger("info", { prefix: "[coderpad]" }) }
: {}),
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
unstubGlobals: true,
},
})
});