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.
90 lines
2.8 KiB
TypeScript
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" });
|
|
});
|
|
});
|