- 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
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
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");
|
|
});
|
|
});
|