Files
bookhoard/web/src/api.test.ts
T
John O'Keefe e5315f1b94 test(web): vitest suites for markdown, filters, password rules, api, storage, toast
Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.

- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
  DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
  round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
  smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
  full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
  Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
  sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
  'warning' entry regression) and render behavior.

Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.

jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.

Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
2026-10-21 15:34:29 -04:00

90 lines
2.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getReadingProgress, handleResponse, handleVoidResponse } from "./api";
// api.ts registers an Alpine component at import time; the toast import it
// carries installs a fetch interceptor. Neither interferes with the stubbed
// global fetch below, but both are exercised by importing the module.
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
describe("handleResponse", () => {
it("parses ok JSON bodies", async () => {
await expect(handleResponse(jsonResponse({ ok: true }))).resolves.toEqual({ ok: true });
});
it("throws the server's error message", async () => {
await expect(handleResponse(jsonResponse({ error: "Nope" }, 404))).rejects.toThrow("Nope");
});
it("falls back to the HTTP status when the body has no message", async () => {
await expect(handleResponse(jsonResponse({ error: "" }, 500))).rejects.toThrow("HTTP 500");
});
it("falls back to Unknown error when the error body is not JSON", async () => {
await expect(handleResponse(new Response("<html>502</html>", { status: 502 }))).rejects.toThrow(
"Unknown error",
);
});
});
describe("handleVoidResponse", () => {
it("resolves on ok regardless of body", async () => {
await expect(
handleVoidResponse(new Response("ignored", { status: 200 })),
).resolves.toBeUndefined();
});
it("throws the server's error message", async () => {
await expect(handleVoidResponse(jsonResponse({ error: "Denied" }, 403))).rejects.toThrow(
"Denied",
);
});
});
describe("getReadingProgress", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns null on 404 (no progress yet)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ error: "none" }, 404)),
);
await expect(getReadingProgress("m1")).resolves.toBeNull();
});
it("returns the progress payload otherwise", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
jsonResponse({ current_page: 3, total_pages: 10, last_read_at: "2026-01-01T00:00:00Z" }),
),
);
const progress = await getReadingProgress("m1");
expect(progress).toMatchObject({ current_page: 3 });
});
it("sends the auth header when a token is stored", async () => {
localStorage.setItem("token", "tok-9");
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
jsonResponse({ error: "none" }, 404),
);
vi.stubGlobal("fetch", fetchMock);
await getReadingProgress("m1");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: "Bearer tok-9" });
});
});