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.
This commit is contained in:
John O'Keefe committed 2026-10-21 15:34:29 -04:00
1 parent 86c4940426
commit e5315f1b94
12 files changed
+685 -6

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
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" });
});
});
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import {
isFlatable,
isRule,
pruneIncomplete,
ruleToFlatParam,
type FilterGroupState,
type FilterRuleState,
} from "./filter_builder";
// ruleToFlatParam/isFlatable encode the PINNED deep-link wire contract:
// these params are byte-compatible with bookmarked URLs already in the
// wild, so assertions here are contract pins, not implementation details.
const rule = (field: string, op: string, values: string[] = []): FilterRuleState => ({
field,
op,
values,
});
const group = (
mode: "all" | "any",
children: (FilterRuleState | FilterGroupState)[],
implicit = false,
): FilterGroupState => ({ mode, children, ...(implicit ? { implicit: true } : {}) });
describe("ruleToFlatParam", () => {
it("maps contains on flat fields to the pinned param", () => {
expect(ruleToFlatParam(rule("author", "contains", ["king"]))).toEqual({
author_filter: "king",
});
});
it("joins multiple values with commas", () => {
expect(ruleToFlatParam(rule("tags", "contains", ["a", "b"]))).toEqual({
tags_filter: "a,b",
});
});
it("maps is on enum fields", () => {
expect(ruleToFlatParam(rule("manga_type", "is", ["manga"]))).toEqual({
manga_type_filter: "manga",
});
});
it("maps published_year at_least/at_most to year_min/year_max", () => {
expect(ruleToFlatParam(rule("published_year", "at_least", ["2000"]))).toEqual({
year_min: "2000",
});
expect(ruleToFlatParam(rule("published_year", "at_most", ["2010"]))).toEqual({
year_max: "2010",
});
});
it("maps published_year between to both bounds", () => {
expect(ruleToFlatParam(rule("published_year", "between", ["2000", "2010"]))).toEqual({
year_min: "2000",
year_max: "2010",
});
});
it("returns null for empty values", () => {
expect(ruleToFlatParam(rule("author", "contains", []))).toBeNull();
});
it("returns null for non-flat-expressible combinations", () => {
// contains on a numeric field has no flat param.
expect(ruleToFlatParam(rule("published_year", "contains", ["2000"]))).toBeNull();
// not_contains is deliberately excluded from the flat contract.
expect(ruleToFlatParam(rule("author", "not_contains", ["king"]))).toBeNull();
});
});
describe("isFlatable", () => {
it("accepts a single ALL group of unique flat rules", () => {
const tree = group("all", [
rule("author", "contains", ["king"]),
rule("published_year", "at_least", ["2000"]),
]);
expect(isFlatable(tree)).toBe(true);
});
it("rejects ANY mode", () => {
expect(isFlatable(group("any", [rule("author", "contains", ["king"])]))).toBe(false);
});
it("rejects nested groups", () => {
expect(isFlatable(group("all", [group("all", [rule("author", "contains", ["king"])])]))).toBe(
false,
);
});
it("rejects duplicate fields", () => {
expect(
isFlatable(
group("all", [rule("author", "contains", ["a"]), rule("author", "contains", ["b"])]),
),
).toBe(false);
});
it("rejects non-flat rules", () => {
expect(isFlatable(group("all", [rule("author", "not_contains", ["king"])]))).toBe(false);
});
});
describe("pruneIncomplete", () => {
it("keeps complete rules untouched", () => {
const tree = group("all", [rule("author", "contains", ["king"])]);
expect(pruneIncomplete(tree, null)).toEqual(tree);
});
it("drops rules with empty values", () => {
const tree = group("all", [
rule("author", "contains", [""]),
rule("series", "contains", ["x"]),
]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
});
it("requires both values for two-arity operators", () => {
const tree = group("all", [rule("published_year", "between", ["2000"])]);
expect(pruneIncomplete(tree, null)).toBeNull();
});
it("keeps none-arity rules without values", () => {
const tree = group("all", [rule("cover", "has")]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("cover", "has", [])]));
});
it("drops groups left empty after pruning", () => {
const tree = group("all", [
group("any", [rule("author", "contains", [""])]),
rule("series", "contains", ["x"]),
]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
});
it("returns null when nothing complete remains", () => {
expect(pruneIncomplete(group("all", [rule("author", "contains", [""])]), null)).toBeNull();
});
it("excludes the rule at the given path", () => {
const tree = group("all", [
rule("author", "contains", ["a"]),
rule("series", "contains", ["b"]),
]);
expect(pruneIncomplete(tree, [0])).toEqual(group("all", [rule("series", "contains", ["b"])]));
});
it("preserves the implicit marker", () => {
const tree = group("all", [rule("author", "contains", ["a"])], true);
expect(pruneIncomplete(tree, null)).toEqual(
group("all", [rule("author", "contains", ["a"])], true),
);
});
it("deep-clones: pruning does not mutate the input tree", () => {
const tree = group("all", [rule("author", "contains", ["a"])]);
const pruned = pruneIncomplete(tree, null);
expect(pruned).not.toBe(tree);
expect(pruned!.children[0]).not.toBe(tree.children[0]);
});
});
describe("isRule", () => {
it("distinguishes rules from groups", () => {
expect(isRule(rule("author", "contains", ["a"]))).toBe(true);
expect(isRule(group("all", []))).toBe(false);
});
});
+3 -3
View File
@@ -345,7 +345,7 @@ export const FLAT_WIRE_NAMES = [
];
// opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
export function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
const joined = rule.values.join(",");
if (!joined) return null;
const flat = FLAT_PARAMS[rule.field];
@@ -378,7 +378,7 @@ function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
// isFlatable — the smart-serialization rule: a single ALL group of
// rules, one rule per field, every rule flat-expressible.
function isFlatable(tree: FilterGroupState): boolean {
export function isFlatable(tree: FilterGroupState): boolean {
if (tree.mode !== "all") return false;
const seen = new Set<string>();
for (const child of tree.children) {
@@ -395,7 +395,7 @@ function isFlatable(tree: FilterGroupState): boolean {
// groups left empty, and can exclude the rule being edited (suggestion
// symmetry). Returns null when nothing complete remains — callers then
// send NO tree at all (an empty group is a server-side 400).
function pruneIncomplete(
export function pruneIncomplete(
group: FilterGroupState,
excludePath: number[] | null,
path: number[] = [],
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { htmlToMarkdown, renderNoteHtml, stripNote } from "./markdown";
// The sanitizer pipeline is the notes feature's XSS defense: markdown-it
// with html disabled, then DOMPurify with a restrictive allow-list. These
// tests pin the security-relevant behavior — loosening any of them should
// require a deliberate decision, never an accident.
describe("renderNoteHtml", () => {
it("renders basic markdown to html", () => {
const out = renderNoteHtml("**bold** and _ital_");
expect(out).toContain("<strong>bold</strong>");
expect(out).toContain("<em>ital</em>");
});
it("returns empty string for empty source", () => {
expect(renderNoteHtml("")).toBe("");
});
it("strips raw script tags (parser-level html disabled)", () => {
const out = renderNoteHtml("<script>alert(1)</script>hello");
expect(out).not.toContain("<script");
expect(out).toContain("hello");
});
it("removes img/input/style/form tags", () => {
const out = renderNoteHtml(
'![x](y.png)\n\n<form action="/steal"></form>\n\n<style>.x{}</style>\n\n<input type="text">',
);
expect(out).not.toContain("<img");
expect(out).not.toContain("<form");
expect(out).not.toContain("<style");
expect(out).not.toContain("<input");
});
it("escapes raw html to inert literal text (html disabled at the parser)", () => {
// html:false makes markdown-it ESCAPE raw html — the output string
// contains the tags as visible text, which is inert wherever the
// compact rows render it.
const out = renderNoteHtml('<span onerror="alert(1)">text</span>');
expect(out).not.toContain("<span");
expect(out).toContain("onerror");
expect(out).toContain("text");
});
it("neutralizes javascript: links at the markdown layer (no anchor emitted)", () => {
// markdown-it's validateLink rejects the URL entirely — the raw syntax
// stays as inert literal text and no <a> element ever reaches the
// sanitizer (which would then scheme-check it a second time).
const out = renderNoteHtml("[click](javascript:alert(1))");
expect(out).not.toContain("<a");
expect(out).toContain("[click](javascript:alert(1))");
});
it("removes markdown-syntax images (FORBID_TAGS)", () => {
const out = renderNoteHtml("![x](y.png)");
expect(out).not.toContain("<img");
});
it("keeps safe https links", () => {
const out = renderNoteHtml("[site](https://example.com)");
expect(out).toContain('href="https://example.com"');
});
it("renders footnotes", () => {
const out = renderNoteHtml("note[^1]\n\n[^1]: the footnote");
expect(out).toContain("footnote-ref");
expect(out).toContain("the footnote");
});
});
describe("stripNote", () => {
it("extracts plain text from markdown", () => {
expect(stripNote("**bold** plain")).toBe("bold plain");
});
it("collapses whitespace and trims", () => {
expect(stripNote("a\n\n b ")).toBe("a b");
});
it("reduces raw html to literal text (safe: html disabled, so tags never become elements)", () => {
// markdown-it escapes the tags and DOMPurify leaves escaped entities
// alone — the result is inert visible text, not executable markup.
expect(stripNote("<script>x</script>safe")).toBe("<script>x</script>safe");
});
it("returns empty string for empty source", () => {
expect(stripNote("")).toBe("");
});
});
describe("htmlToMarkdown", () => {
it("converts emphasis", () => {
expect(htmlToMarkdown("<strong>b</strong>")).toBe("**b**");
});
it("converts atx headings", () => {
expect(htmlToMarkdown("<h2>Title</h2>")).toBe("## Title");
});
it("blanks images entirely", () => {
expect(htmlToMarkdown('<p>a<img src="x.png">b</p>')).toBe("ab");
});
it("converts tables with heading rows to pipe syntax", () => {
const md = htmlToMarkdown(
"<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>",
);
expect(md).toContain("| A | B |");
expect(md).toContain("| 1 | 2 |");
});
it("converts headingless tables to blank-header pipes (never raw html)", () => {
const md = htmlToMarkdown("<table><tr><td>x</td><td>y</td></tr></table>");
expect(md).toContain("| x | y |");
expect(md).not.toContain("<table");
});
});
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initPasswordValidation, loadPasswordRules } from "./password_validation";
// The checklist is display-only (the server enforces), but it gates the
// submit button and mirrors the ACTIVE server rules — these tests pin the
// rules matrix, the fetch fallback behavior, and the submit gating.
function buildDom(): void {
document.body.innerHTML = `
<input id="password" />
<input id="confirm-password" />
<input id="username" />
<input id="email" />
<div id="password-requirements"></div>
<button id="register-btn">Register</button>
`;
}
function setPassword(value: string): void {
(document.getElementById("password") as HTMLInputElement).value = value;
(document.getElementById("password") as HTMLInputElement).dispatchEvent(new Event("input"));
}
const submitButton = (): HTMLButtonElement =>
document.getElementById("register-btn") as HTMLButtonElement;
describe("loadPasswordRules", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("adopts fetched rules", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ min_length: 12, require_upper: false }), { status: 200 }),
),
);
const rules = await loadPasswordRules();
expect(rules).toEqual({
min_length: 12,
require_upper: false,
require_lower: true,
require_number: true,
require_special: true,
});
});
it("falls back to defaults when the endpoint fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 500 })),
);
const rules = await loadPasswordRules();
expect(rules).toEqual({
min_length: 8,
require_upper: true,
require_lower: true,
require_number: true,
require_special: true,
});
});
it("falls back to defaults on invalid payloads", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ min_length: -5 }), { status: 200 })),
);
const rules = await loadPasswordRules();
expect(rules.min_length).toBe(8);
});
});
describe("initPasswordValidation", () => {
beforeEach(() => {
// Deterministic rules: fetch failure lands on the historical defaults.
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 500 })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// initPasswordValidation debounces through window.setTimeout, which under
// jsdom is a distinct timer from the faked global — so these tests wait
// out the real 100ms debounce instead of advancing fake clocks.
const settleDebounce = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 150));
it("generates one checklist row per active rule plus the match row", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
const rows = document.querySelectorAll("#password-requirements li");
expect(rows.length).toBe(6); // length, upper, lower, number, special, match
});
it("starts with the submit button disabled for an empty password", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
expect(submitButton().disabled).toBe(true);
});
it("enables submit when all requirements pass", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
(document.getElementById("username") as HTMLInputElement).value = "user";
(document.getElementById("username") as HTMLInputElement).dispatchEvent(new Event("input"));
(document.getElementById("email") as HTMLInputElement).value = "user@example.com";
(document.getElementById("email") as HTMLInputElement).dispatchEvent(new Event("input"));
await settleDebounce();
expect(submitButton().disabled).toBe(false);
});
it("keeps submit disabled on a confirmation mismatch", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Different1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
await settleDebounce();
expect(submitButton().disabled).toBe(true);
});
it("does not require username/email when requireUsernameEmail is false", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({ requireUsernameEmail: false });
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
await settleDebounce();
expect(submitButton().disabled).toBe(false);
});
});
+1 -1
View File
@@ -63,7 +63,7 @@ function hasNumber(password: string): boolean {
return /[0-9]/.test(password);
}
function hasSpecialChar(password: string): boolean {
return /[!@#$%^&*()_+\-=[]{};':"\\|,.<>?]/.test(password);
return /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>?]/.test(password);
}
function passwordsMatch(password: string, confirm: string): boolean {
return password.length > 0 && password === confirm;
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearAll,
getRefreshToken,
getSelectedLibrary,
getTheme,
getToken,
removeRefreshToken,
removeToken,
setRefreshToken,
setSelectedLibrary,
setTheme,
setToken,
} from "./storage";
afterEach(() => {
clearAll();
});
describe("token storage", () => {
it("round-trips and removes the access token", () => {
expect(getToken()).toBeNull();
setToken("tok-1");
expect(getToken()).toBe("tok-1");
removeToken();
expect(getToken()).toBeNull();
});
it("round-trips and removes the refresh token", () => {
setRefreshToken("r-1");
expect(getRefreshToken()).toBe("r-1");
removeRefreshToken();
expect(getRefreshToken()).toBeNull();
});
});
describe("theme storage", () => {
it("defaults to tokyo-night when unset", () => {
expect(getTheme()).toBe("tokyo-night");
});
it("round-trips a chosen theme", () => {
setTheme("dracula");
expect(getTheme()).toBe("dracula");
});
});
describe("library selection", () => {
it("round-trips a concrete library id", () => {
setSelectedLibrary("abc-123");
expect(getSelectedLibrary()).toBe("abc-123");
});
it("stores the __all__ sentinel for an empty selection and reads it back as empty", () => {
setSelectedLibrary("");
expect(getSelectedLibrary()).toBe("");
});
it("writes the selection cookie for SSR", () => {
setSelectedLibrary("abc-123");
expect(document.cookie).toContain("selectedLibrary=abc-123");
});
});
describe("clearAll", () => {
it("wipes everything", () => {
setToken("t");
setTheme("dracula");
clearAll();
expect(getToken()).toBeNull();
expect(getTheme()).toBe("tokyo-night");
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { getToastConfig, showToast, type ToastType } from "./toast";
// getToastConfig drives every toast's styling; a missing entry for a
// ToastType member crashes showToast at runtime (that bug shipped once —
// the 'warning' variant — which is why this suite exists).
const ALL_TYPES: ToastType[] = ["error", "success", "info", "warning"];
describe("getToastConfig", () => {
it("has a complete config for every ToastType", () => {
for (const type of ALL_TYPES) {
const config = getToastConfig(type);
expect(config, `missing config for "${type}"`).toBeDefined();
expect(config.bgClass, `missing bgClass for "${type}"`).toBeTruthy();
expect(config.icon, `missing icon for "${type}"`).toBeTruthy();
}
});
it("styles the warning variant with the /90 opacity used by siblings", () => {
expect(getToastConfig("warning").bgClass).toBe("bg-yellow-500/90");
});
it("keeps distinct visuals per type", () => {
const classes = ALL_TYPES.map((type) => getToastConfig(type).bgClass);
expect(new Set(classes).size).toBe(ALL_TYPES.length);
});
});
describe("showToast", () => {
it("creates the container and a styled toast", () => {
showToast("Saved", "success");
const container = document.getElementById("toast-container");
expect(container).not.toBeNull();
expect(container?.innerHTML).toContain("Saved");
expect(container?.innerHTML).toContain("bg-green-500/90");
});
it("renders the warning variant without throwing", () => {
// Regression: 'warning' had no config entry and crashed on render.
expect(() => showToast("Careful", "warning")).not.toThrow();
expect(document.getElementById("toast-container")?.innerHTML).toContain("bg-yellow-500/90");
});
});
+1 -1
View File
@@ -262,5 +262,5 @@ const initializeToastSystem = (): void => {
initializeToastSystem();
export { showToast };
export { showToast, getToastConfig };
export type { ToastType };