From e5315f1b9445e2fd43715fe1fc60e22812c6d24e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 21 Oct 2026 15:34:29 -0400 Subject: [PATCH] test(web): vitest suites for markdown, filters, password rules, api, storage, toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 17 +++ package.json | 4 +- vite.config.ts | 4 + web/src/api.test.ts | 89 +++++++++++++++ web/src/filter_builder.test.ts | 171 ++++++++++++++++++++++++++++ web/src/filter_builder.ts | 6 +- web/src/markdown.test.ts | 118 +++++++++++++++++++ web/src/password_validation.test.ts | 159 ++++++++++++++++++++++++++ web/src/password_validation.ts | 2 +- web/src/storage.test.ts | 74 ++++++++++++ web/src/toast.test.ts | 45 ++++++++ web/src/toast.ts | 2 +- 12 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 web/src/api.test.ts create mode 100644 web/src/filter_builder.test.ts create mode 100644 web/src/markdown.test.ts create mode 100644 web/src/password_validation.test.ts create mode 100644 web/src/storage.test.ts create mode 100644 web/src/toast.test.ts diff --git a/AGENTS.md b/AGENTS.md index 71b6fcbc..b729a8e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,23 @@ were name-only across ALL libraries. Fixed; do not regress. tested with synthetic ffprobeReport values. Recipes: docs/contributing/development.md → Media fixtures. +## Web (TypeScript) tests + +- `npm run test` (vp test) runs the vitest suite: colocated `web/src/*.test.ts` + files, jsdom environment (configured in vite.config.ts `test`). Do NOT switch + to happy-dom — DOMPurify's sanitizer output is mangled under it, which makes + the markdown security suite test nothing (verified empirically; jsdom is the + documented fallback for exactly this reason). +- Static gates: `npx tsc --noEmit`, `npx vp check web/src` (format + lint), + `npx vp fmt web/src` to apply formatting. +- Tests are never bundled (build entries are main.ts/reader.ts only). When + touching build config, re-prove it: back-to-back `npm run build:ts` runs must + produce byte-identical hashes, and no test-only strings may appear in + web/static. +- markdown.test.ts pins the notes XSS defenses (FORBID_TAGS, scheme checks, + parser-level html escaping). Loosening any assertion there is a security + decision, not cleanup. + ## Timezones - The server owns display conversion: clients send instants (RFC3339), diff --git a/package.json b/package.json index 4572d221..668c4a36 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development", "build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch", "build": "npm run build:ts && npm run build:css:prod", - "dev": "npm run build:ts:dev && npm run build:css" + "dev": "npm run build:ts:dev && npm run build:css", + "test": "vp test web/src" }, "dependencies": { "@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841", @@ -31,6 +32,7 @@ "@types/markdown-it": "^14.2.0", "@types/turndown": "^5.0.6", "autoprefixer": "^10.4.27", + "jsdom": "^30.1.3", "playwright": "^1.64.1", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", diff --git a/vite.config.ts b/vite.config.ts index fff6b5b6..1b8edb37 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -44,6 +44,10 @@ export default defineConfig({ fmt: { ignorePatterns: ["web/static/**"], }, + test: { + environment: "jsdom", + include: ["web/src/**/*.test.ts"], + }, resolve: { alias: { "@": "/web/src", diff --git a/web/src/api.test.ts b/web/src/api.test.ts new file mode 100644 index 00000000..8c1b73c6 --- /dev/null +++ b/web/src/api.test.ts @@ -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("502", { 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" }); + }); +}); diff --git a/web/src/filter_builder.test.ts b/web/src/filter_builder.test.ts new file mode 100644 index 00000000..cb90d5dd --- /dev/null +++ b/web/src/filter_builder.test.ts @@ -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); + }); +}); diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts index d9c3c822..a71e8e3e 100644 --- a/web/src/filter_builder.ts +++ b/web/src/filter_builder.ts @@ -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 | null { +export function ruleToFlatParam(rule: FilterRuleState): Record | 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 | 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(); 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[] = [], diff --git a/web/src/markdown.test.ts b/web/src/markdown.test.ts new file mode 100644 index 00000000..639248de --- /dev/null +++ b/web/src/markdown.test.ts @@ -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("bold"); + expect(out).toContain("ital"); + }); + + it("returns empty string for empty source", () => { + expect(renderNoteHtml("")).toBe(""); + }); + + it("strips raw script tags (parser-level html disabled)", () => { + const out = renderNoteHtml("hello"); + expect(out).not.toContain(" { + const out = renderNoteHtml( + '![x](y.png)\n\n
\n\n\n\n', + ); + expect(out).not.toContain(" { + // 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('text'); + expect(out).not.toContain(" { + // markdown-it's validateLink rejects the URL entirely — the raw syntax + // stays as inert literal text and no 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(" { + const out = renderNoteHtml("![x](y.png)"); + expect(out).not.toContain(" { + 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("safe")).toBe("safe"); + }); + + it("returns empty string for empty source", () => { + expect(stripNote("")).toBe(""); + }); +}); + +describe("htmlToMarkdown", () => { + it("converts emphasis", () => { + expect(htmlToMarkdown("b")).toBe("**b**"); + }); + + it("converts atx headings", () => { + expect(htmlToMarkdown("

Title

")).toBe("## Title"); + }); + + it("blanks images entirely", () => { + expect(htmlToMarkdown('

ab

')).toBe("ab"); + }); + + it("converts tables with heading rows to pipe syntax", () => { + const md = htmlToMarkdown( + "
AB
12
", + ); + expect(md).toContain("| A | B |"); + expect(md).toContain("| 1 | 2 |"); + }); + + it("converts headingless tables to blank-header pipes (never raw html)", () => { + const md = htmlToMarkdown("
xy
"); + expect(md).toContain("| x | y |"); + expect(md).not.toContain(" + + + +
+ + `; +} + +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 => 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); + }); +}); diff --git a/web/src/password_validation.ts b/web/src/password_validation.ts index 8c4bafa5..600b9074 100644 --- a/web/src/password_validation.ts +++ b/web/src/password_validation.ts @@ -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; diff --git a/web/src/storage.test.ts b/web/src/storage.test.ts new file mode 100644 index 00000000..51ad1468 --- /dev/null +++ b/web/src/storage.test.ts @@ -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"); + }); +}); diff --git a/web/src/toast.test.ts b/web/src/toast.test.ts new file mode 100644 index 00000000..2aff37d7 --- /dev/null +++ b/web/src/toast.test.ts @@ -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"); + }); +}); diff --git a/web/src/toast.ts b/web/src/toast.ts index 84c9cdca..5dee4130 100644 --- a/web/src/toast.ts +++ b/web/src/toast.ts @@ -262,5 +262,5 @@ const initializeToastSystem = (): void => { initializeToastSystem(); -export { showToast }; +export { showToast, getToastConfig }; export type { ToastType };