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:
1 parent
86c4940426
commit
e5315f1b94
12 files changed
+685
-6
No files matched your search
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[] = [],
|
||||
|
||||
@@ -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(
|
||||
'\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("");
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -262,5 +262,5 @@ const initializeToastSystem = (): void => {
|
||||
|
||||
initializeToastSystem();
|
||||
|
||||
export { showToast };
|
||||
export { showToast, getToastConfig };
|
||||
export type { ToastType };
|
||||
Reference in new issue
Block a user