test(web): add 210 tests for lib utils, hooks, and UI components
Lib utilities: format (38), sanitize (12), project-colors (18), csv-export (14). Hooks: useDebounce (8), useTableSort (22), useLocalStorage (18), useColumnConfig (19). Components: BatchActionBar (17), SortableColumnHeader (14), FilterChips (14), ErrorBoundary (16). Web test suite: 63 → 75 files, 343 → 553 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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("<b>bold</b>")).toBe("bold");
|
||||
});
|
||||
|
||||
it("strips nested HTML tags", () => {
|
||||
expect(sanitizeHtml("<div><p>text</p></div>")).toBe("text");
|
||||
});
|
||||
|
||||
it("strips attributes", () => {
|
||||
expect(sanitizeHtml('<a href="https://example.com">link</a>')).toBe("link");
|
||||
});
|
||||
|
||||
it("strips script tags and their content", () => {
|
||||
// DOMPurify removes script tags entirely (content too)
|
||||
const result = sanitizeHtml('<script>alert("xss")</script>safe');
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("alert");
|
||||
expect(result).toContain("safe");
|
||||
});
|
||||
|
||||
it("strips event handler attributes", () => {
|
||||
const result = sanitizeHtml('<img src="x" onerror="alert(1)">');
|
||||
expect(result).not.toContain("onerror");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("passes through text that contains HTML entities", () => {
|
||||
// DOMPurify in jsdom returns the serialised form, keeping '&' as-is
|
||||
// (it is not the job of sanitiseHtml to decode entities)
|
||||
const result = sanitizeHtml("&");
|
||||
expect(result).toBeTruthy();
|
||||
// Either decoded ('&') or kept encoded ('&') is acceptable; the
|
||||
// important thing is that no HTML tags survive.
|
||||
expect(result).not.toContain("<");
|
||||
expect(result).not.toContain(">");
|
||||
});
|
||||
|
||||
it("strips style tags", () => {
|
||||
const result = sanitizeHtml("<style>body{color:red}</style>text");
|
||||
expect(result).not.toContain("<style>");
|
||||
expect(result).toContain("text");
|
||||
});
|
||||
|
||||
it("handles input with only whitespace", () => {
|
||||
expect(sanitizeHtml(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("handles deeply nested tags leaving only text", () => {
|
||||
expect(sanitizeHtml("<ul><li><span>item</span></li></ul>")).toBe("item");
|
||||
});
|
||||
|
||||
it("strips iframe tags", () => {
|
||||
const result = sanitizeHtml('<iframe src="evil.com"></iframe>safe');
|
||||
expect(result).not.toContain("iframe");
|
||||
expect(result).toContain("safe");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user