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

+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);
});
});