diff --git a/apps/web/src/components/ui/BatchActionBar.test.tsx b/apps/web/src/components/ui/BatchActionBar.test.tsx
new file mode 100644
index 0000000..32ddaa1
--- /dev/null
+++ b/apps/web/src/components/ui/BatchActionBar.test.tsx
@@ -0,0 +1,138 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import { BatchActionBar } from "./BatchActionBar.js";
+
+describe("BatchActionBar", () => {
+ describe("rendering", () => {
+ it("renders nothing when count is 0", () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders the bar when count is greater than 0", () => {
+ render();
+ expect(screen.getByText("3 selected")).toBeInTheDocument();
+ });
+
+ it("displays the correct count", () => {
+ render();
+ expect(screen.getByText("42 selected")).toBeInTheDocument();
+ });
+
+ it("renders action buttons", () => {
+ const actions = [
+ { label: "Edit", onClick: vi.fn() },
+ { label: "Archive", onClick: vi.fn() },
+ ];
+ render();
+ expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument();
+ });
+
+ it("renders the Clear button", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
+ });
+ });
+
+ describe("action variants", () => {
+ it("applies danger styling to actions with variant='danger'", () => {
+ const actions = [{ label: "Delete", onClick: vi.fn(), variant: "danger" as const }];
+ render();
+ const btn = screen.getByRole("button", { name: "Delete" });
+ expect(btn.className).toContain("bg-red-600");
+ });
+
+ it("applies default styling to actions without a variant", () => {
+ const actions = [{ label: "Edit", onClick: vi.fn() }];
+ render();
+ const btn = screen.getByRole("button", { name: "Edit" });
+ expect(btn.className).toContain("bg-white/10");
+ });
+
+ it("applies default styling to actions with variant='default'", () => {
+ const actions = [{ label: "Edit", onClick: vi.fn(), variant: "default" as const }];
+ render();
+ const btn = screen.getByRole("button", { name: "Edit" });
+ expect(btn.className).toContain("bg-white/10");
+ });
+ });
+
+ describe("disabled state", () => {
+ it("disables an action button when disabled prop is true", () => {
+ const actions = [{ label: "Export", onClick: vi.fn(), disabled: true }];
+ render();
+ expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
+ });
+
+ it("enables an action button when disabled prop is false", () => {
+ const actions = [{ label: "Export", onClick: vi.fn(), disabled: false }];
+ render();
+ expect(screen.getByRole("button", { name: "Export" })).not.toBeDisabled();
+ });
+
+ it("does not fire onClick when action button is disabled", async () => {
+ const user = userEvent.setup();
+ const handleClick = vi.fn();
+ const actions = [{ label: "Export", onClick: handleClick, disabled: true }];
+ render();
+ await user.click(screen.getByRole("button", { name: "Export" }));
+ expect(handleClick).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("interactions", () => {
+ it("calls action onClick when an action button is clicked", async () => {
+ const user = userEvent.setup();
+ const handleClick = vi.fn();
+ const actions = [{ label: "Edit", onClick: handleClick }];
+ render();
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+ expect(handleClick).toHaveBeenCalledOnce();
+ });
+
+ it("calls onClear when the Clear button is clicked", async () => {
+ const user = userEvent.setup();
+ const handleClear = vi.fn();
+ render();
+ await user.click(screen.getByRole("button", { name: "Clear" }));
+ expect(handleClear).toHaveBeenCalledOnce();
+ });
+
+ it("calls each action's onClick independently", async () => {
+ const user = userEvent.setup();
+ const handleEdit = vi.fn();
+ const handleDelete = vi.fn();
+ const actions = [
+ { label: "Edit", onClick: handleEdit },
+ { label: "Delete", onClick: handleDelete, variant: "danger" as const },
+ ];
+ render();
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+ expect(handleEdit).toHaveBeenCalledOnce();
+ expect(handleDelete).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+ expect(handleDelete).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("conditional rendering on count change", () => {
+ it("renders when count transitions from 0 to positive", () => {
+ const { rerender } = render();
+ expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByText("5 selected")).toBeInTheDocument();
+ });
+
+ it("hides the bar when count goes back to 0", () => {
+ const { rerender } = render();
+ expect(screen.getByText("3 selected")).toBeInTheDocument();
+
+ rerender();
+ expect(screen.queryByText(/selected/)).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/components/ui/ErrorBoundary.test.tsx b/apps/web/src/components/ui/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..261ba8f
--- /dev/null
+++ b/apps/web/src/components/ui/ErrorBoundary.test.tsx
@@ -0,0 +1,224 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import type { ErrorInfo } from "react";
+import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";
+
+// Suppress React's console.error output during error boundary tests
+let consoleErrorSpy: ReturnType;
+
+beforeEach(() => {
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+});
+
+afterEach(() => {
+ consoleErrorSpy.mockRestore();
+});
+
+// A component that unconditionally throws
+function ThrowingChild({ message = "Test error" }: { message?: string }) {
+ throw new Error(message);
+}
+
+// A component that throws only when `shouldThrow` is true
+function ConditionalThrower({ shouldThrow }: { shouldThrow: boolean }) {
+ if (shouldThrow) throw new Error("Conditional error");
+ return OK
;
+}
+
+describe("ErrorBoundary", () => {
+ describe("normal rendering", () => {
+ it("renders children when no error is thrown", () => {
+ render(
+
+ Hello World
+ ,
+ );
+ expect(screen.getByText("Hello World")).toBeInTheDocument();
+ });
+
+ it("renders multiple children normally", () => {
+ render(
+
+ First
+ Second
+ ,
+ );
+ expect(screen.getByText("First")).toBeInTheDocument();
+ expect(screen.getByText("Second")).toBeInTheDocument();
+ });
+ });
+
+ describe("error catching", () => {
+ it("catches a thrown error and renders the default fallback UI", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ });
+
+ it("displays the error message in the default fallback", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Custom error message")).toBeInTheDocument();
+ });
+
+ it("renders a custom fallback node instead of the default when provided", () => {
+ render(
+ Custom fallback}>
+
+ ,
+ );
+ expect(screen.getByText("Custom fallback")).toBeInTheDocument();
+ expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
+ });
+
+ it("calls onError callback when an error is caught", () => {
+ const handleError = vi.fn();
+ render(
+
+
+ ,
+ );
+ expect(handleError).toHaveBeenCalledOnce();
+ const [error, info] = handleError.mock.calls[0] as [Error, ErrorInfo];
+ expect(error.message).toBe("Tracked error");
+ expect(info).toHaveProperty("componentStack");
+ });
+
+ it("does not call onError when no error is thrown", () => {
+ const handleError = vi.fn();
+ render(
+
+ No error here
+ ,
+ );
+ expect(handleError).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("default fallback UI", () => {
+ it("renders the 'Something went wrong' heading", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
+ });
+
+ it("renders a 'Try again' button", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
+ });
+
+ it("renders a 'Go to dashboard' button", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Go to dashboard" })).toBeInTheDocument();
+ });
+
+ it("shows a generic message when error.message is empty", () => {
+ function ThrowEmptyMessage() {
+ const e = new Error("");
+ throw e;
+ }
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getByText("An unexpected error occurred. The team has been notified."),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("reset / Try again", () => {
+ it("clears the error state and re-renders children when Try again is clicked", async () => {
+ const user = userEvent.setup();
+
+ // Use a mutable ref-like object to control throwing from outside JSX
+ let shouldThrow = true;
+
+ function ControlledThrower() {
+ if (shouldThrow) throw new Error("Controlled error");
+ return OK
;
+ }
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+
+ // Stop throwing, then click "Try again" so ErrorBoundary resets its state
+ shouldThrow = false;
+ await user.click(screen.getByRole("button", { name: "Try again" }));
+
+ // Trigger a re-render so ControlledThrower runs with shouldThrow=false
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByText("OK")).toBeInTheDocument();
+ expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
+ });
+ });
+});
+
+describe("DefaultErrorFallback", () => {
+ it("renders the heading", () => {
+ render();
+ expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
+ });
+
+ it("renders the error message", () => {
+ render();
+ expect(screen.getByText("Specific error")).toBeInTheDocument();
+ });
+
+ it("falls back to generic text when error message is empty", () => {
+ render();
+ expect(
+ screen.getByText("An unexpected error occurred. The team has been notified."),
+ ).toBeInTheDocument();
+ });
+
+ it("calls reset when Try again is clicked", async () => {
+ const user = userEvent.setup();
+ const handleReset = vi.fn();
+ render();
+ await user.click(screen.getByRole("button", { name: "Try again" }));
+ expect(handleReset).toHaveBeenCalledOnce();
+ });
+
+ it("navigates to /dashboard when Go to dashboard is clicked", async () => {
+ const user = userEvent.setup();
+ // jsdom does not navigate; spy on the assignment
+ const originalHref = window.location.href;
+ Object.defineProperty(window, "location", {
+ value: { href: originalHref },
+ writable: true,
+ });
+ render();
+ await user.click(screen.getByRole("button", { name: "Go to dashboard" }));
+ expect(window.location.href).toBe("/dashboard");
+ });
+});
diff --git a/apps/web/src/components/ui/FilterChips.test.tsx b/apps/web/src/components/ui/FilterChips.test.tsx
new file mode 100644
index 0000000..8a66cdc
--- /dev/null
+++ b/apps/web/src/components/ui/FilterChips.test.tsx
@@ -0,0 +1,135 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import { FilterChips, type Chip } from "./FilterChips.js";
+
+describe("FilterChips", () => {
+ describe("rendering", () => {
+ it("renders nothing when chips array is empty", () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders chip labels when chips are provided", () => {
+ const chips: Chip[] = [
+ { label: "Active", onRemove: vi.fn() },
+ { label: "Design", onRemove: vi.fn() },
+ ];
+ render();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ expect(screen.getByText("Design")).toBeInTheDocument();
+ });
+
+ it("renders a remove button for each chip", () => {
+ const chips: Chip[] = [
+ { label: "Active", onRemove: vi.fn() },
+ { label: "Design", onRemove: vi.fn() },
+ ];
+ render();
+ expect(screen.getByRole("button", { name: "Remove filter: Active" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Remove filter: Design" })).toBeInTheDocument();
+ });
+
+ it("renders the 'Clear all' button when chips are present", () => {
+ const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
+ render();
+ expect(screen.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
+ });
+
+ it("does not render 'Clear all' when chips array is empty", () => {
+ render();
+ expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
+ });
+
+ it("renders a single chip correctly", () => {
+ const chips: Chip[] = [{ label: "Pending", onRemove: vi.fn() }];
+ render();
+ expect(screen.getByText("Pending")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Remove filter: Pending" })).toBeInTheDocument();
+ });
+ });
+
+ describe("interactions", () => {
+ it("calls onRemove of the correct chip when its remove button is clicked", async () => {
+ const user = userEvent.setup();
+ const handleRemoveActive = vi.fn();
+ const handleRemoveDesign = vi.fn();
+ const chips: Chip[] = [
+ { label: "Active", onRemove: handleRemoveActive },
+ { label: "Design", onRemove: handleRemoveDesign },
+ ];
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Remove filter: Active" }));
+ expect(handleRemoveActive).toHaveBeenCalledOnce();
+ expect(handleRemoveDesign).not.toHaveBeenCalled();
+ });
+
+ it("calls onRemove for the second chip independently", async () => {
+ const user = userEvent.setup();
+ const handleRemoveActive = vi.fn();
+ const handleRemoveDesign = vi.fn();
+ const chips: Chip[] = [
+ { label: "Active", onRemove: handleRemoveActive },
+ { label: "Design", onRemove: handleRemoveDesign },
+ ];
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Remove filter: Design" }));
+ expect(handleRemoveDesign).toHaveBeenCalledOnce();
+ expect(handleRemoveActive).not.toHaveBeenCalled();
+ });
+
+ it("calls onClearAll when the Clear all button is clicked", async () => {
+ const user = userEvent.setup();
+ const handleClearAll = vi.fn();
+ const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Clear all" }));
+ expect(handleClearAll).toHaveBeenCalledOnce();
+ });
+
+ it("does not call other handlers when Clear all is clicked", async () => {
+ const user = userEvent.setup();
+ const handleRemove = vi.fn();
+ const handleClearAll = vi.fn();
+ const chips: Chip[] = [{ label: "Active", onRemove: handleRemove }];
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Clear all" }));
+ expect(handleRemove).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("conditional rendering on chip changes", () => {
+ it("shows chips after transitioning from empty to non-empty", () => {
+ const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
+ const { rerender } = render();
+ expect(screen.queryByText("Active")).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ });
+
+ it("hides when all chips are removed", () => {
+ const chips: Chip[] = [{ label: "Active", onRemove: vi.fn() }];
+ const { rerender } = render();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+
+ rerender();
+ expect(screen.queryByText("Active")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
+ });
+ });
+
+ describe("chip remove button aria-label", () => {
+ it("uses the chip label in the aria-label", () => {
+ const chips: Chip[] = [{ label: "In Progress", onRemove: vi.fn() }];
+ render();
+ expect(
+ screen.getByRole("button", { name: "Remove filter: In Progress" }),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/components/ui/SortableColumnHeader.test.tsx b/apps/web/src/components/ui/SortableColumnHeader.test.tsx
new file mode 100644
index 0000000..82e6f47
--- /dev/null
+++ b/apps/web/src/components/ui/SortableColumnHeader.test.tsx
@@ -0,0 +1,266 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import { SortableColumnHeader } from "./SortableColumnHeader.js";
+
+// Helper: render inside a table/thead/tr to satisfy HTML semantics
+function renderInTable(ui: React.ReactElement) {
+ return render(
+ ,
+ );
+}
+
+describe("SortableColumnHeader", () => {
+ describe("rendering", () => {
+ it("renders the label text", () => {
+ renderInTable(
+ ,
+ );
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ });
+
+ it("renders a button for the sortable label", () => {
+ renderInTable(
+ ,
+ );
+ expect(screen.getByRole("button", { name: /budget/i })).toBeInTheDocument();
+ });
+
+ it("renders inside a element", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ expect(container.querySelector("th")).toBeInTheDocument();
+ });
+ });
+
+ describe("sort direction icons", () => {
+ it("shows no active direction when sortField does not match field", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ // Neither chevron should be brand-colored
+ const paths = container.querySelectorAll("path");
+ paths.forEach((path) => {
+ expect(path.className.baseVal).not.toContain("stroke-brand-600");
+ });
+ });
+
+ it("highlights the up chevron when sorted asc on this field", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const paths = container.querySelectorAll("path");
+ // First path = up chevron (asc)
+ expect(paths[0]?.className.baseVal).toContain("stroke-brand-600");
+ // Second path = down chevron should be gray
+ expect(paths[1]?.className.baseVal).toContain("stroke-gray-300");
+ });
+
+ it("highlights the down chevron when sorted desc on this field", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const paths = container.querySelectorAll("path");
+ // First path = up chevron should be gray
+ expect(paths[0]?.className.baseVal).toContain("stroke-gray-300");
+ // Second path = down chevron (desc)
+ expect(paths[1]?.className.baseVal).toContain("stroke-brand-600");
+ });
+
+ it("shows no active direction when sortDir is null for this field", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const paths = container.querySelectorAll("path");
+ paths.forEach((path) => {
+ expect(path.className.baseVal).not.toContain("stroke-brand-600");
+ });
+ });
+ });
+
+ describe("interactions", () => {
+ it("calls onSort with the field when the button is clicked", async () => {
+ const user = userEvent.setup();
+ const handleSort = vi.fn();
+ renderInTable(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /name/i }));
+ expect(handleSort).toHaveBeenCalledOnce();
+ expect(handleSort).toHaveBeenCalledWith("name");
+ });
+
+ it("passes the correct field string when multiple columns exist", async () => {
+ const user = userEvent.setup();
+ const handleSort = vi.fn();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /budget/i }));
+ expect(handleSort).toHaveBeenCalledWith("budget");
+ });
+ });
+
+ describe("alignment", () => {
+ it("applies justify-start for left alignment (default)", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const div = container.querySelector("th > div");
+ expect(div?.className).toContain("justify-start");
+ });
+
+ it("applies justify-end for right alignment", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const div = container.querySelector("th > div");
+ expect(div?.className).toContain("justify-end");
+ });
+
+ it("applies justify-center for center alignment", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ const div = container.querySelector("th > div");
+ expect(div?.className).toContain("justify-center");
+ });
+ });
+
+ describe("tooltip", () => {
+ it("does not render an info tooltip when tooltip prop is absent", () => {
+ renderInTable(
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "More information" })).not.toBeInTheDocument();
+ });
+
+ it("renders an info tooltip button when tooltip prop is provided", () => {
+ renderInTable(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "More information" })).toBeInTheDocument();
+ });
+ });
+
+ describe("custom className", () => {
+ it("forwards className to the | element", () => {
+ const { container } = renderInTable(
+ ,
+ );
+ expect(container.querySelector("th")?.className).toContain("w-48");
+ });
+ });
+});
diff --git a/apps/web/src/hooks/useColumnConfig.test.ts b/apps/web/src/hooks/useColumnConfig.test.ts
new file mode 100644
index 0000000..c94b34b
--- /dev/null
+++ b/apps/web/src/hooks/useColumnConfig.test.ts
@@ -0,0 +1,297 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import type { ColumnDef, ColumnPreferences } from "@capakraken/shared";
+
+// ---------------------------------------------------------------------------
+// Mock the tRPC client so the hook can be tested without a real server
+// ---------------------------------------------------------------------------
+const mockGetColumnPreferences = vi.fn();
+const mockSetColumnPreferences = vi.fn();
+
+vi.mock("~/lib/trpc/client.js", () => ({
+ trpc: {
+ user: {
+ getColumnPreferences: {
+ useQuery: () => ({
+ data: mockGetColumnPreferences(),
+ }),
+ },
+ setColumnPreferences: {
+ useMutation: () => ({
+ mutate: mockSetColumnPreferences,
+ }),
+ },
+ },
+ },
+}));
+
+// Import after the mock is registered
+const { useColumnConfig } = await import("./useColumnConfig.js");
+
+// ---------------------------------------------------------------------------
+// localStorage stub
+// ---------------------------------------------------------------------------
+function createLocalStorageStub() {
+ let store: Record = {};
+ return {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value;
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key];
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ _store: () => store,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+const builtinColumns: ColumnDef[] = [
+ { key: "name", label: "Name", defaultVisible: true, hideable: false },
+ { key: "age", label: "Age", defaultVisible: true, hideable: true },
+ { key: "status", label: "Status", defaultVisible: false, hideable: true },
+];
+
+const customColumns: ColumnDef[] = [
+ { key: "custom1", label: "Custom 1", defaultVisible: true, hideable: true, isCustom: true },
+ { key: "custom2", label: "Custom 2", defaultVisible: false, hideable: true, isCustom: true },
+];
+
+describe("useColumnConfig", () => {
+ let localStorageStub: ReturnType;
+
+ beforeEach(() => {
+ localStorageStub = createLocalStorageStub();
+ vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageStub as unknown as Storage);
+ // Default: no server preferences
+ mockGetColumnPreferences.mockReturnValue(undefined);
+ mockSetColumnPreferences.mockReset();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // -------------------------------------------------------------------------
+ // allColumns
+ // -------------------------------------------------------------------------
+ describe("allColumns", () => {
+ it("combines builtinColumns and customColumns", () => {
+ const { result } = renderHook(() =>
+ useColumnConfig("resources", builtinColumns, customColumns),
+ );
+ expect(result.current.allColumns).toHaveLength(builtinColumns.length + customColumns.length);
+ });
+
+ it("returns only builtinColumns when customColumns is omitted", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+ expect(result.current.allColumns).toEqual(builtinColumns);
+ });
+
+ it("puts builtinColumns before customColumns", () => {
+ const { result } = renderHook(() =>
+ useColumnConfig("resources", builtinColumns, customColumns),
+ );
+ const keys = result.current.allColumns.map((c) => c.key);
+ expect(keys).toEqual(["name", "age", "status", "custom1", "custom2"]);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // visibleKeys — default fallback (no localStorage, no server prefs)
+ // -------------------------------------------------------------------------
+ describe("visibleKeys — defaults", () => {
+ it("includes columns with defaultVisible=true", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+ expect(result.current.visibleKeys).toContain("name");
+ expect(result.current.visibleKeys).toContain("age");
+ });
+
+ it("excludes columns with defaultVisible=false", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+ expect(result.current.visibleKeys).not.toContain("status");
+ });
+
+ it("always includes non-hideable columns when a saved list is present", () => {
+ // alwaysVisible is only force-injected when merging with a saved key list.
+ // When falling back to defaults, only columns with defaultVisible=true are shown.
+ const cols: ColumnDef[] = [
+ { key: "id", label: "ID", defaultVisible: true, hideable: false },
+ { key: "opt", label: "Opt", defaultVisible: false, hideable: true },
+ { key: "extra", label: "Extra", defaultVisible: false, hideable: true },
+ ];
+ // Provide a saved list that omits "id" — the hook must still inject it
+ localStorageStub.getItem.mockReturnValue(JSON.stringify(["opt"]));
+ const { result } = renderHook(() => useColumnConfig("resources", cols));
+ expect(result.current.visibleKeys).toContain("id");
+ expect(result.current.visibleKeys).toContain("opt");
+ expect(result.current.visibleKeys).not.toContain("extra");
+ });
+
+ it("shows only defaultVisible=true columns when no saved list exists", () => {
+ const cols: ColumnDef[] = [
+ { key: "id", label: "ID", defaultVisible: false, hideable: false },
+ { key: "opt", label: "Opt", defaultVisible: false, hideable: true },
+ ];
+ const { result } = renderHook(() => useColumnConfig("resources", cols));
+ // No saved list → falls back to defaultVisible; neither column qualifies
+ expect(result.current.visibleKeys).toHaveLength(0);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // visibleKeys — localStorage takes priority
+ // -------------------------------------------------------------------------
+ describe("visibleKeys — localStorage persistence", () => {
+ it("uses the saved keys from localStorage on mount", () => {
+ localStorageStub.getItem.mockReturnValue(JSON.stringify(["status"]));
+
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ // "name" is non-hideable so it must always appear; "status" is from localStorage
+ expect(result.current.visibleKeys).toContain("name");
+ expect(result.current.visibleKeys).toContain("status");
+ });
+
+ it("filters out stale keys that no longer exist in allColumns", () => {
+ localStorageStub.getItem.mockReturnValue(JSON.stringify(["age", "obsoleteKey"]));
+
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ expect(result.current.visibleKeys).not.toContain("obsoleteKey");
+ expect(result.current.visibleKeys).toContain("age");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // visibleKeys — server prefs fallback
+ // -------------------------------------------------------------------------
+ describe("visibleKeys — server preferences fallback", () => {
+ it("uses server preferences when localStorage is empty", () => {
+ const serverPrefs: ColumnPreferences = {
+ resources: { visible: ["status", "age"] },
+ };
+ mockGetColumnPreferences.mockReturnValue(serverPrefs);
+
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ expect(result.current.visibleKeys).toContain("status");
+ expect(result.current.visibleKeys).toContain("age");
+ // non-hideable always present
+ expect(result.current.visibleKeys).toContain("name");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // visibleColumns
+ // -------------------------------------------------------------------------
+ describe("visibleColumns", () => {
+ it("returns only columns whose keys are in visibleKeys", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+ const visibleSet = new Set(result.current.visibleKeys);
+ for (const col of result.current.visibleColumns) {
+ expect(visibleSet.has(col.key)).toBe(true);
+ }
+ });
+
+ it("orders visibleColumns to match the order of visibleKeys", () => {
+ localStorageStub.getItem.mockReturnValue(JSON.stringify(["age", "name"]));
+
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ const colKeys = result.current.visibleColumns.map((c) => c.key);
+ // "name" is non-hideable and always inserted first in visibleKeys logic,
+ // then "age" from localStorage; ordering must follow visibleKeys
+ expect(colKeys.indexOf("name")).toBeLessThan(colKeys.indexOf("age"));
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // setVisible
+ // -------------------------------------------------------------------------
+ describe("setVisible", () => {
+ it("updates visibleKeys immediately (optimistic)", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ act(() => {
+ result.current.setVisible(["name", "status"]);
+ });
+
+ expect(result.current.visibleKeys).toContain("status");
+ });
+
+ it("persists the new visible keys to localStorage", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ act(() => {
+ result.current.setVisible(["name", "age"]);
+ });
+
+ expect(localStorageStub.setItem).toHaveBeenCalledWith(
+ "colvis_resources",
+ JSON.stringify(["name", "age"]),
+ );
+ });
+
+ it("calls the server mutation with the correct payload", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ act(() => {
+ result.current.setVisible(["name", "age"]);
+ });
+
+ expect(mockSetColumnPreferences).toHaveBeenCalledWith({
+ view: "resources",
+ visible: ["name", "age"],
+ });
+ });
+
+ it("reflects the new keys in visibleColumns after setVisible", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ act(() => {
+ result.current.setVisible(["name", "status"]);
+ });
+
+ const keys = result.current.visibleColumns.map((c) => c.key);
+ expect(keys).toContain("status");
+ expect(keys).not.toContain("age");
+ });
+
+ it("can hide all hideable columns", () => {
+ const { result } = renderHook(() => useColumnConfig("resources", builtinColumns));
+
+ act(() => {
+ // Only "name" (non-hideable) in the list
+ result.current.setVisible(["name"]);
+ });
+
+ expect(result.current.visibleKeys).toEqual(["name"]);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // ViewKey variation
+ // -------------------------------------------------------------------------
+ describe("view isolation", () => {
+ it("reads from the correct localStorage key for the given view", () => {
+ renderHook(() => useColumnConfig("projects", builtinColumns));
+ expect(localStorageStub.getItem).toHaveBeenCalledWith("colvis_projects");
+ });
+
+ it("writes to the correct localStorage key for the given view", () => {
+ const { result } = renderHook(() => useColumnConfig("projects", builtinColumns));
+
+ act(() => {
+ result.current.setVisible(["name"]);
+ });
+
+ expect(localStorageStub.setItem).toHaveBeenCalledWith("colvis_projects", expect.any(String));
+ });
+ });
+});
diff --git a/apps/web/src/hooks/useDebounce.test.ts b/apps/web/src/hooks/useDebounce.test.ts
new file mode 100644
index 0000000..3cec6e7
--- /dev/null
+++ b/apps/web/src/hooks/useDebounce.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useDebounce } from "./useDebounce.js";
+
+describe("useDebounce", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns the initial value immediately", () => {
+ const { result } = renderHook(() => useDebounce("hello", 300));
+ expect(result.current).toBe("hello");
+ });
+
+ it("does not update the value before the delay expires", () => {
+ const { result, rerender } = renderHook(
+ ({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
+ { initialProps: { value: "initial", delay: 300 } },
+ );
+
+ rerender({ value: "updated", delay: 300 });
+
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+
+ expect(result.current).toBe("initial");
+ });
+
+ it("updates the value after the delay expires", () => {
+ const { result, rerender } = renderHook(
+ ({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
+ { initialProps: { value: "initial", delay: 300 } },
+ );
+
+ rerender({ value: "updated", delay: 300 });
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(result.current).toBe("updated");
+ });
+
+ it("resets the timer when the value changes before the delay expires", () => {
+ const { result, rerender } = renderHook(
+ ({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
+ { initialProps: { value: "initial", delay: 300 } },
+ );
+
+ rerender({ value: "first", delay: 300 });
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ // Timer not expired yet — value should still be "initial"
+ expect(result.current).toBe("initial");
+
+ rerender({ value: "second", delay: 300 });
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ // Only 200ms since the second change — still debouncing
+ expect(result.current).toBe("initial");
+
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+ // Now 300ms since "second" — should have resolved
+ expect(result.current).toBe("second");
+ });
+
+ it("works with number values", () => {
+ const { result, rerender } = renderHook(
+ ({ value, delay }: { value: number; delay: number }) => useDebounce(value, delay),
+ { initialProps: { value: 0, delay: 500 } },
+ );
+
+ rerender({ value: 42, delay: 500 });
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(result.current).toBe(42);
+ });
+
+ it("works with object values", () => {
+ const initial = { name: "Alice" };
+ const updated = { name: "Bob" };
+
+ const { result, rerender } = renderHook(
+ ({ value, delay }: { value: { name: string }; delay: number }) => useDebounce(value, delay),
+ { initialProps: { value: initial, delay: 200 } },
+ );
+
+ rerender({ value: updated, delay: 200 });
+
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+
+ expect(result.current).toEqual({ name: "Bob" });
+ });
+
+ it("handles a delay of 0", () => {
+ const { result, rerender } = renderHook(
+ ({ value }: { value: string }) => useDebounce(value, 0),
+ { initialProps: { value: "initial" } },
+ );
+
+ rerender({ value: "instant" });
+
+ act(() => {
+ vi.advanceTimersByTime(0);
+ });
+
+ expect(result.current).toBe("instant");
+ });
+
+ it("updates the debounced value when delay changes", () => {
+ const { result, rerender } = renderHook(
+ ({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
+ { initialProps: { value: "hello", delay: 1000 } },
+ );
+
+ // Reduce delay — old timer is cleared, new shorter timer starts
+ rerender({ value: "hello", delay: 100 });
+
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ expect(result.current).toBe("hello");
+ });
+});
diff --git a/apps/web/src/hooks/useLocalStorage.test.ts b/apps/web/src/hooks/useLocalStorage.test.ts
new file mode 100644
index 0000000..7c54de9
--- /dev/null
+++ b/apps/web/src/hooks/useLocalStorage.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useLocalStorage } from "./useLocalStorage.js";
+
+// Helper to create a fresh in-memory localStorage stub
+function createLocalStorageStub() {
+ let store: Record = {};
+ return {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value;
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key];
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ };
+}
+
+describe("useLocalStorage", () => {
+ let localStorageStub: ReturnType;
+
+ beforeEach(() => {
+ localStorageStub = createLocalStorageStub();
+ vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageStub as unknown as Storage);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("initial value", () => {
+ it("returns the defaultValue when localStorage is empty", () => {
+ const { result } = renderHook(() => useLocalStorage("test-key", "default"));
+ expect(result.current[0]).toBe("default");
+ });
+
+ it("returns the stored value when localStorage already has data", () => {
+ localStorageStub.getItem.mockReturnValue(JSON.stringify("stored"));
+ const { result } = renderHook(() => useLocalStorage("test-key", "default"));
+ expect(result.current[0]).toBe("stored");
+ });
+
+ it("merges stored object with defaultValue for schema evolution", () => {
+ const stored = { existing: "value" };
+ localStorageStub.getItem.mockReturnValue(JSON.stringify(stored));
+ const defaultValue = { existing: "old", newField: "fallback" };
+
+ const { result } = renderHook(() => useLocalStorage("obj-key", defaultValue));
+
+ expect(result.current[0]).toEqual({ existing: "value", newField: "fallback" });
+ });
+
+ it("does not merge when the stored value is not a plain object", () => {
+ localStorageStub.getItem.mockReturnValue(JSON.stringify([1, 2, 3]));
+ const { result } = renderHook(() => useLocalStorage("arr-key", [0]));
+ expect(result.current[0]).toEqual([1, 2, 3]);
+ });
+
+ it("returns defaultValue when stored JSON is malformed", () => {
+ localStorageStub.getItem.mockReturnValue("not-valid-json{{{");
+ const { result } = renderHook(() => useLocalStorage("bad-key", 99));
+ expect(result.current[0]).toBe(99);
+ });
+ });
+
+ describe("set function — direct value", () => {
+ it("updates the in-memory state", () => {
+ const { result } = renderHook(() => useLocalStorage("key", "initial"));
+
+ act(() => {
+ result.current[1]("updated");
+ });
+
+ expect(result.current[0]).toBe("updated");
+ });
+
+ it("persists the new value to localStorage", () => {
+ const { result } = renderHook(() => useLocalStorage("key", "initial"));
+
+ act(() => {
+ result.current[1]("saved");
+ });
+
+ expect(localStorageStub.setItem).toHaveBeenCalledWith("key", JSON.stringify("saved"));
+ });
+
+ it("handles numeric values", () => {
+ const { result } = renderHook(() => useLocalStorage("num", 0));
+
+ act(() => {
+ result.current[1](42);
+ });
+
+ expect(result.current[0]).toBe(42);
+ expect(localStorageStub.setItem).toHaveBeenCalledWith("num", "42");
+ });
+
+ it("handles boolean values", () => {
+ const { result } = renderHook(() => useLocalStorage("bool", false));
+
+ act(() => {
+ result.current[1](true);
+ });
+
+ expect(result.current[0]).toBe(true);
+ });
+
+ it("handles object values", () => {
+ const { result } = renderHook(() => useLocalStorage("obj", { a: 1 }));
+
+ act(() => {
+ result.current[1]({ a: 2, b: 3 });
+ });
+
+ expect(result.current[0]).toEqual({ a: 2, b: 3 });
+ });
+ });
+
+ describe("set function — updater callback", () => {
+ it("passes the current value to the updater", () => {
+ const { result } = renderHook(() => useLocalStorage("count", 5));
+
+ act(() => {
+ result.current[1]((prev) => prev + 1);
+ });
+
+ expect(result.current[0]).toBe(6);
+ });
+
+ it("supports multiple sequential updater calls", () => {
+ const { result } = renderHook(() => useLocalStorage("count", 0));
+
+ act(() => {
+ result.current[1]((prev) => prev + 1);
+ result.current[1]((prev) => prev + 1);
+ });
+
+ expect(result.current[0]).toBe(2);
+ });
+
+ it("can spread objects in updater", () => {
+ const { result } = renderHook(() => useLocalStorage>("map", {}));
+
+ act(() => {
+ result.current[1]((prev) => ({ ...prev, x: 10 }));
+ });
+ act(() => {
+ result.current[1]((prev) => ({ ...prev, y: 20 }));
+ });
+
+ expect(result.current[0]).toEqual({ x: 10, y: 20 });
+ });
+ });
+
+ describe("changeEventName cross-instance sync", () => {
+ it("dispatches a CustomEvent on write when changeEventName is provided", () => {
+ const dispatchSpy = vi.spyOn(window, "dispatchEvent");
+ const { result } = renderHook(() => useLocalStorage("key", "a", "myEvent"));
+
+ act(() => {
+ result.current[1]("b");
+ });
+
+ const dispatched = dispatchSpy.mock.calls.find(
+ ([e]) => e instanceof CustomEvent && e.type === "myEvent",
+ );
+ expect(dispatched).toBeDefined();
+ });
+
+ it("does not dispatch a CustomEvent when changeEventName is omitted", () => {
+ const dispatchSpy = vi.spyOn(window, "dispatchEvent");
+ const { result } = renderHook(() => useLocalStorage("key", "a"));
+
+ act(() => {
+ result.current[1]("b");
+ });
+
+ const customEvents = dispatchSpy.mock.calls.filter(([e]) => e instanceof CustomEvent);
+ expect(customEvents).toHaveLength(0);
+ });
+
+ it("updates state when the matching CustomEvent fires on window", () => {
+ const { result } = renderHook(() => useLocalStorage("key", "initial", "syncEvent"));
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("syncEvent", { detail: "from-other-instance" }));
+ });
+
+ expect(result.current[0]).toBe("from-other-instance");
+ });
+
+ it("does not respond to events after unmount", () => {
+ const { result, unmount } = renderHook(() => useLocalStorage("key", "initial", "syncEvent"));
+
+ unmount();
+
+ // Should not throw or update after unmount
+ act(() => {
+ window.dispatchEvent(new CustomEvent("syncEvent", { detail: "post-unmount" }));
+ });
+
+ // Value should remain whatever it was at unmount time
+ expect(result.current[0]).toBe("initial");
+ });
+ });
+
+ describe("key change", () => {
+ it("reads from the new key when the key prop changes", () => {
+ localStorageStub.getItem.mockImplementation((k: string) => {
+ if (k === "key-a") return JSON.stringify("value-a");
+ if (k === "key-b") return JSON.stringify("value-b");
+ return null;
+ });
+
+ const { result, rerender } = renderHook(
+ ({ key }: { key: string }) => useLocalStorage(key, "default"),
+ { initialProps: { key: "key-a" } },
+ );
+ expect(result.current[0]).toBe("value-a");
+
+ rerender({ key: "key-b" });
+ expect(result.current[0]).toBe("value-b");
+ });
+ });
+});
diff --git a/apps/web/src/hooks/useTableSort.test.ts b/apps/web/src/hooks/useTableSort.test.ts
new file mode 100644
index 0000000..791589f
--- /dev/null
+++ b/apps/web/src/hooks/useTableSort.test.ts
@@ -0,0 +1,285 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useTableSort } from "./useTableSort.js";
+
+interface Person {
+ name: string;
+ age: number;
+ score?: number | null;
+}
+
+const people: Person[] = [
+ { name: "Charlie", age: 30 },
+ { name: "Alice", age: 25 },
+ { name: "Bob", age: 35 },
+];
+
+describe("useTableSort", () => {
+ describe("initial state", () => {
+ it("returns rows unsorted when no initial options are provided", () => {
+ const { result } = renderHook(() => useTableSort(people));
+ expect(result.current.sorted).toEqual(people);
+ expect(result.current.sortField).toBeNull();
+ expect(result.current.sortDir).toBeNull();
+ });
+
+ it("applies initialField and initialDir when provided", () => {
+ const { result } = renderHook(() =>
+ useTableSort(people, { initialField: "name", initialDir: "asc" }),
+ );
+ expect(result.current.sortField).toBe("name");
+ expect(result.current.sortDir).toBe("asc");
+ expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
+ });
+
+ it("sorts descending when initialDir is desc", () => {
+ const { result } = renderHook(() =>
+ useTableSort(people, { initialField: "name", initialDir: "desc" }),
+ );
+ expect(result.current.sorted.map((p) => p.name)).toEqual(["Charlie", "Bob", "Alice"]);
+ });
+ });
+
+ describe("toggle", () => {
+ it("sets a new sort field with asc direction on first toggle", () => {
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name");
+ });
+
+ expect(result.current.sortField).toBe("name");
+ expect(result.current.sortDir).toBe("asc");
+ });
+
+ it("cycles asc → desc on the same field", () => {
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name");
+ });
+ expect(result.current.sortDir).toBe("asc");
+
+ act(() => {
+ result.current.toggle("name");
+ });
+ expect(result.current.sortDir).toBe("desc");
+ });
+
+ it("cycles desc → null on the same field", () => {
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name");
+ }); // asc
+ act(() => {
+ result.current.toggle("name");
+ }); // desc
+ act(() => {
+ result.current.toggle("name");
+ }); // null
+
+ expect(result.current.sortField).toBe("name");
+ expect(result.current.sortDir).toBeNull();
+ });
+
+ it("resets to asc when switching to a different field", () => {
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name");
+ }); // name asc
+ act(() => {
+ result.current.toggle("name");
+ }); // name desc
+ act(() => {
+ result.current.toggle("age");
+ }); // age asc (new field)
+
+ expect(result.current.sortField).toBe("age");
+ expect(result.current.sortDir).toBe("asc");
+ });
+
+ it("accepts a custom getValue extractor", () => {
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name", (row) => row.name.toLowerCase());
+ });
+
+ expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
+ });
+ });
+
+ describe("reset", () => {
+ it("clears sortField and sortDir", () => {
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name");
+ });
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.sortField).toBeNull();
+ expect(result.current.sortDir).toBeNull();
+ });
+
+ it("returns the original row order after reset", () => {
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name");
+ });
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.sorted).toEqual(people);
+ });
+ });
+
+ describe("sorted output", () => {
+ it("sorts strings alphabetically ascending", () => {
+ const { result } = renderHook(() => useTableSort(people));
+ act(() => {
+ result.current.toggle("name");
+ });
+ expect(result.current.sorted.map((p) => p.name)).toEqual(["Alice", "Bob", "Charlie"]);
+ });
+
+ it("sorts strings alphabetically descending", () => {
+ const { result } = renderHook(() => useTableSort(people));
+ act(() => {
+ result.current.toggle("name");
+ }); // asc
+ act(() => {
+ result.current.toggle("name");
+ }); // desc
+ expect(result.current.sorted.map((p) => p.name)).toEqual(["Charlie", "Bob", "Alice"]);
+ });
+
+ it("sorts numbers ascending", () => {
+ const { result } = renderHook(() => useTableSort(people));
+ act(() => {
+ result.current.toggle("age");
+ });
+ expect(result.current.sorted.map((p) => p.age)).toEqual([25, 30, 35]);
+ });
+
+ it("sorts numbers descending", () => {
+ const { result } = renderHook(() => useTableSort(people));
+ act(() => {
+ result.current.toggle("age");
+ }); // asc
+ act(() => {
+ result.current.toggle("age");
+ }); // desc
+ expect(result.current.sorted.map((p) => p.age)).toEqual([35, 30, 25]);
+ });
+
+ it("places nulls last regardless of direction", () => {
+ const rows: Person[] = [
+ { name: "A", age: 1, score: null },
+ { name: "B", age: 2, score: 10 },
+ { name: "C", age: 3, score: null },
+ { name: "D", age: 4, score: 5 },
+ ];
+ const { result } = renderHook(() => useTableSort(rows));
+
+ act(() => {
+ result.current.toggle("score");
+ }); // asc
+ expect(result.current.sorted.map((r) => r.score)).toEqual([5, 10, null, null]);
+
+ act(() => {
+ result.current.toggle("score");
+ }); // desc
+ expect(result.current.sorted.map((r) => r.score)).toEqual([10, 5, null, null]);
+ });
+
+ it("does not mutate the original rows array", () => {
+ const originalOrder = people.map((p) => p.name);
+ const { result } = renderHook(() => useTableSort(people));
+
+ act(() => {
+ result.current.toggle("name");
+ });
+
+ expect(people.map((p) => p.name)).toEqual(originalOrder);
+ });
+
+ it("returns original rows when sortDir is null even if sortField is set", () => {
+ const { result } = renderHook(() => useTableSort(people));
+ // Cycle through to null dir
+ act(() => {
+ result.current.toggle("name");
+ }); // asc
+ act(() => {
+ result.current.toggle("name");
+ }); // desc
+ act(() => {
+ result.current.toggle("name");
+ }); // null
+ expect(result.current.sorted).toEqual(people);
+ });
+ });
+
+ describe("onSortChange callback", () => {
+ it("does not call onSortChange on the initial render", () => {
+ const onSortChange = vi.fn();
+ renderHook(() =>
+ useTableSort(people, {
+ initialField: "name",
+ initialDir: "asc",
+ onSortChange,
+ }),
+ );
+ expect(onSortChange).not.toHaveBeenCalled();
+ });
+
+ it("calls onSortChange when the sort field changes", () => {
+ const onSortChange = vi.fn();
+ const { result } = renderHook(() => useTableSort(people, { onSortChange }));
+
+ act(() => {
+ result.current.toggle("name");
+ });
+
+ expect(onSortChange).toHaveBeenCalledWith("name", "asc");
+ });
+
+ it("calls onSortChange with null when reset", () => {
+ const onSortChange = vi.fn();
+ const { result } = renderHook(() => useTableSort(people, { onSortChange }));
+
+ act(() => {
+ result.current.toggle("age");
+ });
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(onSortChange).toHaveBeenLastCalledWith(null, null);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles an empty rows array", () => {
+ const { result } = renderHook(() => useTableSort([]));
+ act(() => {
+ result.current.toggle("name");
+ });
+ expect(result.current.sorted).toEqual([]);
+ });
+
+ it("handles a single-element array", () => {
+ const { result } = renderHook(() => useTableSort([people[0]!]));
+ act(() => {
+ result.current.toggle("name");
+ });
+ expect(result.current.sorted).toEqual([people[0]]);
+ });
+ });
+});
diff --git a/apps/web/src/lib/csv-export.test.ts b/apps/web/src/lib/csv-export.test.ts
new file mode 100644
index 0000000..b2f26ee
--- /dev/null
+++ b/apps/web/src/lib/csv-export.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest";
+import { generateCsv } from "./csv-export.js";
+
+// downloadCsv is a browser-only side-effect function (creates DOM elements and
+// triggers a download). It is intentionally excluded here — testing it would
+// require mocking the entire DOM download flow and adds no value for a pure
+// utility test suite.
+
+type Row = { name: string; value: number | null; note: string };
+
+const columns = [
+ { header: "Name", accessor: (r: Row) => r.name },
+ { header: "Value", accessor: (r: Row) => r.value },
+ { header: "Note", accessor: (r: Row) => r.note },
+];
+
+describe("generateCsv", () => {
+ it("produces a header row followed by a data row", () => {
+ const rows: Row[] = [{ name: "Alice", value: 42, note: "ok" }];
+ const csv = generateCsv(rows, columns);
+ const lines = csv.split("\n");
+ expect(lines[0]).toBe("Name,Value,Note");
+ expect(lines[1]).toBe("Alice,42,ok");
+ });
+
+ it("returns only the header row when there are no data rows", () => {
+ const csv = generateCsv([], columns);
+ // generateCsv always appends '\n' between header and body.
+ // With an empty body the result is "header\n"
+ expect(csv).toBe("Name,Value,Note\n");
+ });
+
+ it("handles multiple rows", () => {
+ const rows: Row[] = [
+ { name: "Alice", value: 1, note: "a" },
+ { name: "Bob", value: 2, note: "b" },
+ { name: "Carol", value: 3, note: "c" },
+ ];
+ const csv = generateCsv(rows, columns);
+ const lines = csv.split("\n");
+ expect(lines).toHaveLength(4); // header + 3 data rows
+ expect(lines[1]).toBe("Alice,1,a");
+ expect(lines[2]).toBe("Bob,2,b");
+ expect(lines[3]).toBe("Carol,3,c");
+ });
+
+ it("wraps cell values that contain a comma in double quotes", () => {
+ const rows: Row[] = [{ name: "Smith, John", value: 10, note: "ok" }];
+ const csv = generateCsv(rows, columns);
+ const dataLine = csv.split("\n")[1];
+ expect(dataLine).toBe('"Smith, John",10,ok');
+ });
+
+ it("escapes double quotes inside cell values by doubling them", () => {
+ const rows: Row[] = [{ name: 'Say "hello"', value: 0, note: "x" }];
+ const csv = generateCsv(rows, columns);
+ const dataLine = csv.split("\n")[1];
+ expect(dataLine).toBe('"Say ""hello""",0,x');
+ });
+
+ it("wraps cells that contain a newline in double quotes", () => {
+ const rows: Row[] = [{ name: "line1\nline2", value: 0, note: "x" }];
+ const csv = generateCsv(rows, columns);
+ // The header is the first line; the wrapped cell starts on line 2
+ const raw = csv.split("\n");
+ // Header + quoted cell (which contains an embedded newline) + remainder
+ expect(raw[0]).toBe("Name,Value,Note");
+ expect(raw[1]).toBe('"line1');
+ expect(raw[2]).toContain("line2");
+ });
+
+ it("converts null accessor results to empty strings", () => {
+ const rows: Row[] = [{ name: "Test", value: null, note: "" }];
+ const csv = generateCsv(rows, columns);
+ const dataLine = csv.split("\n")[1];
+ expect(dataLine).toBe("Test,,");
+ });
+
+ it("converts undefined accessor results to empty strings", () => {
+ type Partial = { name?: string };
+ const partialCols = [{ header: "Name", accessor: (r: Partial) => r.name }];
+ const csv = generateCsv([{}] as Partial[], partialCols);
+ expect(csv.split("\n")[1]).toBe("");
+ });
+
+ it("wraps header cells that contain a comma", () => {
+ const colsWithComma = [{ header: "Last, First", accessor: (r: Row) => r.name }];
+ const csv = generateCsv([], colsWithComma);
+ expect(csv.startsWith('"Last, First"')).toBe(true);
+ });
+
+ it("handles numeric zero values without treating them as empty", () => {
+ const rows: Row[] = [{ name: "Zero", value: 0, note: "" }];
+ const csv = generateCsv(rows, columns);
+ const dataLine = csv.split("\n")[1];
+ expect(dataLine).toBe("Zero,0,");
+ });
+
+ it("handles boolean accessor return values", () => {
+ const boolCols = [
+ { header: "Active", accessor: (_r: unknown) => true },
+ { header: "Deleted", accessor: (_r: unknown) => false },
+ ];
+ const csv = generateCsv([{}], boolCols);
+ expect(csv.split("\n")[1]).toBe("true,false");
+ });
+
+ it("handles a value that contains both a comma and a double quote", () => {
+ const rows: Row[] = [{ name: 'a, "b"', value: 1, note: "" }];
+ const csv = generateCsv(rows, columns);
+ const dataLine = csv.split("\n")[1];
+ expect(dataLine).toBe('"a, ""b""",1,');
+ });
+
+ it("does not wrap plain numeric strings", () => {
+ const numCols = [{ header: "Num", accessor: (_r: unknown) => "12345" }];
+ const csv = generateCsv([{}], numCols);
+ expect(csv.split("\n")[1]).toBe("12345");
+ });
+
+ it("generates correct CSV for a single-column dataset", () => {
+ const singleCol = [{ header: "ID", accessor: (_r: unknown) => "abc" }];
+ const csv = generateCsv([{}, {}], singleCol);
+ const lines = csv.split("\n");
+ expect(lines[0]).toBe("ID");
+ expect(lines[1]).toBe("abc");
+ expect(lines[2]).toBe("abc");
+ });
+});
diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts
new file mode 100644
index 0000000..c2b9c3c
--- /dev/null
+++ b/apps/web/src/lib/format.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it } from "vitest";
+import {
+ formatCents,
+ formatDate,
+ formatDateLong,
+ formatDateMedium,
+ formatDateShort,
+ formatMonthYear,
+ formatMoney,
+ toDateInputValue,
+} from "./format.js";
+
+// ---------------------------------------------------------------------------
+// toDateInputValue
+// ---------------------------------------------------------------------------
+describe("toDateInputValue", () => {
+ it("returns empty string for null", () => {
+ expect(toDateInputValue(null)).toBe("");
+ });
+
+ it("returns empty string for undefined", () => {
+ expect(toDateInputValue(undefined)).toBe("");
+ });
+
+ it("formats a Date object to yyyy-mm-dd", () => {
+ const d = new Date(2026, 2, 4); // local: 4 March 2026
+ expect(toDateInputValue(d)).toBe("2026-03-04");
+ });
+
+ it("pads single-digit month and day with leading zeros", () => {
+ const d = new Date(2026, 0, 9); // local: 9 January 2026
+ expect(toDateInputValue(d)).toBe("2026-01-09");
+ });
+
+ it("accepts an ISO date string", () => {
+ // Use a date string that is unambiguous when parsed by Date constructor
+ const result = toDateInputValue("2026-11-30T00:00:00");
+ expect(result).toBe("2026-11-30");
+ });
+
+ it("handles end-of-year boundary", () => {
+ const d = new Date(2025, 11, 31); // 31 December 2025
+ expect(toDateInputValue(d)).toBe("2025-12-31");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatDate
+// ---------------------------------------------------------------------------
+describe("formatDate", () => {
+ it("returns empty string for null", () => {
+ expect(formatDate(null)).toBe("");
+ });
+
+ it("returns empty string for undefined", () => {
+ expect(formatDate(undefined)).toBe("");
+ });
+
+ it("formats a Date using en-GB locale (dd/mm/yyyy)", () => {
+ // Use a date created from a local timestamp so locale formatting is stable
+ const d = new Date(2026, 2, 4); // 4 March 2026
+ expect(formatDate(d)).toBe("04/03/2026");
+ });
+
+ it("accepts a string input", () => {
+ const result = formatDate("2026-07-15T00:00:00");
+ expect(result).toBe("15/07/2026");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatDateShort
+// ---------------------------------------------------------------------------
+describe("formatDateShort", () => {
+ it("returns DD MMM format", () => {
+ const d = new Date(2026, 2, 4); // 4 March 2026
+ expect(formatDateShort(d)).toBe("04 Mar");
+ });
+
+ it("pads day with leading zero", () => {
+ const d = new Date(2026, 0, 5); // 5 January 2026
+ expect(formatDateShort(d)).toBe("05 Jan");
+ });
+
+ it("accepts a date string", () => {
+ const result = formatDateShort("2026-12-01T00:00:00");
+ expect(result).toBe("01 Dec");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatMonthYear
+// ---------------------------------------------------------------------------
+describe("formatMonthYear", () => {
+ it("returns MMM YY format", () => {
+ const d = new Date(2026, 2, 1); // March 2026
+ expect(formatMonthYear(d)).toBe("Mar 26");
+ });
+
+ it("handles year boundary (December 2025)", () => {
+ const d = new Date(2025, 11, 1);
+ expect(formatMonthYear(d)).toBe("Dec 25");
+ });
+
+ it("accepts a date string", () => {
+ const result = formatMonthYear("2026-07-01T00:00:00");
+ expect(result).toBe("Jul 26");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatDateLong
+// ---------------------------------------------------------------------------
+describe("formatDateLong", () => {
+ it("returns long form including full month name and year", () => {
+ const d = new Date(2026, 2, 4); // 4 March 2026
+ expect(formatDateLong(d)).toBe("4 March 2026");
+ });
+
+ it("accepts a date string", () => {
+ const result = formatDateLong("2026-01-01T00:00:00");
+ expect(result).toBe("1 January 2026");
+ });
+
+ it("handles single-digit day without zero-padding", () => {
+ const d = new Date(2026, 5, 7); // 7 June 2026
+ expect(formatDateLong(d)).toBe("7 June 2026");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatDateMedium
+// ---------------------------------------------------------------------------
+describe("formatDateMedium", () => {
+ it("returns empty string for null", () => {
+ expect(formatDateMedium(null)).toBe("");
+ });
+
+ it("returns empty string for undefined", () => {
+ expect(formatDateMedium(undefined)).toBe("");
+ });
+
+ it("returns compact date with short month and year", () => {
+ const d = new Date(2026, 2, 16); // 16 March 2026
+ expect(formatDateMedium(d)).toBe("16 Mar 2026");
+ });
+
+ it("accepts a date string", () => {
+ const result = formatDateMedium("2026-08-05T00:00:00");
+ expect(result).toBe("5 Aug 2026");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatMoney
+// ---------------------------------------------------------------------------
+describe("formatMoney", () => {
+ it("formats zero cents as 0 EUR", () => {
+ // The de-DE locale uses non-breaking space before the currency symbol
+ const result = formatMoney(0);
+ expect(result).toContain("0");
+ expect(result).toContain("€");
+ });
+
+ it("formats null as 0 EUR", () => {
+ const result = formatMoney(null);
+ expect(result).toContain("0");
+ expect(result).toContain("€");
+ });
+
+ it("formats undefined as 0 EUR", () => {
+ const result = formatMoney(undefined);
+ expect(result).toContain("0");
+ expect(result).toContain("€");
+ });
+
+ it("converts cents to euros (divides by 100)", () => {
+ // 123400 cents = 1234 EUR, de-DE: "1.234 €"
+ const result = formatMoney(123400);
+ expect(result).toContain("1.234");
+ expect(result).toContain("€");
+ });
+
+ it("rounds to whole euros by default (fractionDigits = 0)", () => {
+ const result = formatMoney(9950); // 99.50 EUR → rounds to 100
+ expect(result).toContain("100");
+ });
+
+ it("respects fractionDigits = 2 for precise display", () => {
+ const result = formatMoney(9950, "EUR", 2); // 99.50 EUR
+ expect(result).toContain("99,50");
+ });
+
+ it("supports other currencies", () => {
+ const result = formatMoney(10000, "USD", 0); // 100 USD
+ expect(result).toContain("100");
+ expect(result).toContain("$");
+ });
+
+ it("formats negative values correctly", () => {
+ const result = formatMoney(-50000); // -500 EUR
+ expect(result).toContain("500");
+ // de-DE locale may use a regular hyphen-minus or a minus sign
+ const hasNegativeSign = result.includes("−") || result.includes("-");
+ expect(hasNegativeSign).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatCents
+// ---------------------------------------------------------------------------
+describe("formatCents", () => {
+ it("returns '-' for null", () => {
+ expect(formatCents(null)).toBe("-");
+ });
+
+ it("returns '-' for undefined", () => {
+ expect(formatCents(undefined)).toBe("-");
+ });
+
+ it("formats zero as '0,00'", () => {
+ expect(formatCents(0)).toBe("0,00");
+ });
+
+ it("formats 100 cents as '1,00'", () => {
+ expect(formatCents(100)).toBe("1,00");
+ });
+
+ it("formats 123456 cents as '1.234,56' (de-DE locale)", () => {
+ expect(formatCents(123456)).toBe("1.234,56");
+ });
+
+ it("formats negative cents correctly", () => {
+ expect(formatCents(-100)).toBe("-1,00");
+ });
+
+ it("always shows exactly 2 decimal places", () => {
+ expect(formatCents(50)).toBe("0,50");
+ expect(formatCents(5)).toBe("0,05");
+ });
+});
diff --git a/apps/web/src/lib/project-colors.test.ts b/apps/web/src/lib/project-colors.test.ts
new file mode 100644
index 0000000..b62966f
--- /dev/null
+++ b/apps/web/src/lib/project-colors.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "vitest";
+import { buildProjectColorMap, getProjectColor, getProjectHex } from "./project-colors.js";
+
+// The palette has exactly 16 entries — exported indirectly through the type.
+const PALETTE_SIZE = 16;
+
+describe("getProjectColor", () => {
+ it("returns an object with bg, dark, border, and hex fields", () => {
+ const color = getProjectColor("proj-1");
+ expect(color).toHaveProperty("bg");
+ expect(color).toHaveProperty("dark");
+ expect(color).toHaveProperty("border");
+ expect(color).toHaveProperty("hex");
+ });
+
+ it("is deterministic — same ID always yields the same color", () => {
+ const id = "stable-id-abc";
+ const a = getProjectColor(id);
+ const b = getProjectColor(id);
+ expect(a).toBe(b); // same object reference from the const palette
+ });
+
+ it("returns different colors for different IDs (at least across a sample)", () => {
+ const colors = new Set(
+ Array.from({ length: 20 }, (_, i) => getProjectColor(`project-${i}`).hex),
+ );
+ // With 20 IDs spread across 16 slots we expect several distinct colors
+ expect(colors.size).toBeGreaterThan(1);
+ });
+
+ it("handles an empty string ID without throwing", () => {
+ expect(() => getProjectColor("")).not.toThrow();
+ });
+
+ it("handles a very long ID without throwing", () => {
+ const longId = "x".repeat(1000);
+ expect(() => getProjectColor(longId)).not.toThrow();
+ });
+
+ it("returned hex value starts with '#'", () => {
+ const { hex } = getProjectColor("test-id");
+ expect(hex).toMatch(/^#[0-9a-fA-F]{6}$/);
+ });
+
+ it("cycles through all 16 palette slots given enough IDs", () => {
+ // Generate enough IDs to guarantee every palette slot is hit
+ const hexSet = new Set();
+ for (let i = 0; i < 200; i++) {
+ hexSet.add(getProjectColor(`id-collision-test-${i}`).hex);
+ }
+ expect(hexSet.size).toBe(PALETTE_SIZE);
+ });
+
+ it("bg class contains a valid Tailwind color segment", () => {
+ const { bg } = getProjectColor("tailwind-check");
+ expect(bg).toMatch(/^bg-[a-z]+-500\/70$/);
+ });
+
+ it("border class follows expected pattern", () => {
+ const { border } = getProjectColor("border-check");
+ expect(border).toMatch(/^border-[a-z]+-600$/);
+ });
+});
+
+describe("getProjectHex", () => {
+ it("appends the default alpha suffix 'B3'", () => {
+ const result = getProjectHex("proj-alpha");
+ expect(result).toMatch(/^#[0-9a-fA-F]{6}B3$/);
+ });
+
+ it("appends a custom alpha suffix", () => {
+ const result = getProjectHex("proj-alpha", "FF");
+ expect(result).toMatch(/^#[0-9a-fA-F]{6}FF$/);
+ });
+
+ it("is deterministic for the same ID", () => {
+ const a = getProjectHex("same-id");
+ const b = getProjectHex("same-id");
+ expect(a).toBe(b);
+ });
+
+ it("returns a different hex when a different alpha is supplied", () => {
+ const withDefault = getProjectHex("id-x");
+ const withCustom = getProjectHex("id-x", "00");
+ expect(withDefault).not.toBe(withCustom);
+ // The base hex part should be the same
+ expect(withDefault.slice(0, 7)).toBe(withCustom.slice(0, 7));
+ });
+});
+
+describe("buildProjectColorMap", () => {
+ it("returns a Map with an entry for each ID", () => {
+ const ids = ["proj-a", "proj-b", "proj-c"];
+ const map = buildProjectColorMap(ids);
+ expect(map.size).toBe(3);
+ for (const id of ids) {
+ expect(map.has(id)).toBe(true);
+ }
+ });
+
+ it("returns an empty Map for an empty array", () => {
+ const map = buildProjectColorMap([]);
+ expect(map.size).toBe(0);
+ });
+
+ it("each entry equals the direct getProjectColor result", () => {
+ const ids = ["x", "y", "z"];
+ const map = buildProjectColorMap(ids);
+ for (const id of ids) {
+ expect(map.get(id)).toBe(getProjectColor(id));
+ }
+ });
+
+ it("handles duplicate IDs — last write wins (same color anyway)", () => {
+ const map = buildProjectColorMap(["dup", "dup", "dup"]);
+ expect(map.size).toBe(1);
+ expect(map.get("dup")).toBe(getProjectColor("dup"));
+ });
+
+ it("does not mutate the input array", () => {
+ const ids = ["a", "b"];
+ const copy = [...ids];
+ buildProjectColorMap(ids);
+ expect(ids).toEqual(copy);
+ });
+});
diff --git a/apps/web/src/lib/sanitize.test.ts b/apps/web/src/lib/sanitize.test.ts
new file mode 100644
index 0000000..2e2d841
--- /dev/null
+++ b/apps/web/src/lib/sanitize.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { sanitizeHtml } from "./sanitize.js";
+
+// The vitest environment is jsdom, so `window` is defined.
+// DOMPurify will operate in client mode and strip tags.
+
+describe("sanitizeHtml", () => {
+ it("returns plain text unchanged", () => {
+ expect(sanitizeHtml("Hello, World!")).toBe("Hello, World!");
+ });
+
+ it("strips a single HTML tag", () => {
+ expect(sanitizeHtml("bold")).toBe("bold");
+ });
+
+ it("strips nested HTML tags", () => {
+ expect(sanitizeHtml("")).toBe("text");
+ });
+
+ it("strips attributes", () => {
+ expect(sanitizeHtml('link')).toBe("link");
+ });
+
+ it("strips script tags and their content", () => {
+ // DOMPurify removes script tags entirely (content too)
+ const result = sanitizeHtml('safe');
+ expect(result).not.toContain(" |