diff --git a/apps/web/src/components/ui/AnimatedModal.test.tsx b/apps/web/src/components/ui/AnimatedModal.test.tsx index af24e24..8974130 100644 --- a/apps/web/src/components/ui/AnimatedModal.test.tsx +++ b/apps/web/src/components/ui/AnimatedModal.test.tsx @@ -51,6 +51,18 @@ describe("AnimatedModal", () => { expect(dialog).toBeInTheDocument(); expect(dialog).toHaveAttribute("aria-modal", "true"); }); + + it("sets aria-labelledby when ariaLabelledBy prop is provided", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-labelledby", "modal-title"); + }); + + it("does not set aria-labelledby when ariaLabelledBy is omitted", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).not.toHaveAttribute("aria-labelledby"); + }); }); describe("backdrop close", () => { diff --git a/apps/web/src/components/ui/ConfirmDialog.test.tsx b/apps/web/src/components/ui/ConfirmDialog.test.tsx index 66d03aa..e7091d4 100644 --- a/apps/web/src/components/ui/ConfirmDialog.test.tsx +++ b/apps/web/src/components/ui/ConfirmDialog.test.tsx @@ -62,4 +62,19 @@ describe("ConfirmDialog", () => { await userEvent.click(backdrop); expect(onCancel).toHaveBeenCalledOnce(); }); + + it("does NOT call onCancel when clicking inside the dialog panel", async () => { + const onCancel = vi.fn(); + render(); + // Click on the title text — this is inside the dialog panel, not the backdrop + await userEvent.click(screen.getByText("Delete item?")); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("does NOT call onCancel when clicking the message text inside the dialog panel", async () => { + const onCancel = vi.fn(); + render(); + await userEvent.click(screen.getByText("This action cannot be undone.")); + expect(onCancel).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/ui/DateInput.test.tsx b/apps/web/src/components/ui/DateInput.test.tsx index 23ce1d3..86f0b8e 100644 --- a/apps/web/src/components/ui/DateInput.test.tsx +++ b/apps/web/src/components/ui/DateInput.test.tsx @@ -166,4 +166,42 @@ describe("DateInput", () => { expect(nativeDateInput).toHaveAttribute("aria-hidden", "true"); }); }); + + describe("edge cases", () => { + it("strips non-digit characters typed into the input (autoSlash rejects them)", async () => { + const user = userEvent.setup(); + const { textInput } = setup(); + await user.type(textInput, "ab/cd/efgh"); + // autoSlash strips all non-digits, so nothing should remain + expect(textInput).toHaveValue(""); + }); + + it("returns an ISO string for a leap year Feb 29 (29/02/2024)", async () => { + const user = userEvent.setup(); + const { textInput, onChange } = setup(); + await user.type(textInput, "29022024"); + // displayToISO only validates numeric ranges (day 1-31, month 1-12, year 1900-2100) + // it does NOT check calendar correctness, so this passes and calls onChange + expect(textInput).toHaveValue("29/02/2024"); + expect(onChange).toHaveBeenLastCalledWith("2024-02-29"); + }); + + it("returns an ISO string for Feb 29 on a non-leap year (29/02/2023) — no calendar validation", async () => { + const user = userEvent.setup(); + const { textInput, onChange } = setup(); + await user.type(textInput, "29022023"); + // displayToISO does not validate calendar correctness — it only checks range bounds + expect(textInput).toHaveValue("29/02/2023"); + expect(onChange).toHaveBeenLastCalledWith("2023-02-29"); + }); + + it("returns an ISO string for day 31 on a 30-day month (31/04/2024) — no calendar validation", async () => { + const user = userEvent.setup(); + const { textInput, onChange } = setup(); + await user.type(textInput, "31042024"); + // displayToISO only rejects day > 31 or month > 12, not calendar-invalid combos + expect(textInput).toHaveValue("31/04/2024"); + expect(onChange).toHaveBeenLastCalledWith("2024-04-31"); + }); + }); }); diff --git a/apps/web/src/components/ui/ErrorBoundary.test.tsx b/apps/web/src/components/ui/ErrorBoundary.test.tsx index a7a6fd2..50cee39 100644 --- a/apps/web/src/components/ui/ErrorBoundary.test.tsx +++ b/apps/web/src/components/ui/ErrorBoundary.test.tsx @@ -183,6 +183,22 @@ describe("ErrorBoundary", () => { }); }); +describe("edge cases", () => { + it("catches a thrown string (non-Error object)", () => { + function ThrowString(): React.ReactNode { + throw "string error"; + } + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + spy.mockRestore(); + }); +}); + describe("DefaultErrorFallback", () => { it("renders the heading", () => { render(); diff --git a/apps/web/src/components/ui/FilterBar.test.tsx b/apps/web/src/components/ui/FilterBar.test.tsx index 688d03c..157cd2b 100644 --- a/apps/web/src/components/ui/FilterBar.test.tsx +++ b/apps/web/src/components/ui/FilterBar.test.tsx @@ -50,4 +50,22 @@ describe("FilterBar", () => { await userEvent.click(screen.getByText("Clear filters")); expect(onClear).toHaveBeenCalledOnce(); }); + + it("has role='search' on the container", () => { + render( + + Filters + , + ); + expect(screen.getByRole("search")).toBeInTheDocument(); + }); + + it("has aria-label='Filters' on the container", () => { + render( + + Filters + , + ); + expect(screen.getByRole("search")).toHaveAttribute("aria-label", "Filters"); + }); }); diff --git a/apps/web/src/components/ui/SortableColumnHeader.test.tsx b/apps/web/src/components/ui/SortableColumnHeader.test.tsx index 82e6f47..423d645 100644 --- a/apps/web/src/components/ui/SortableColumnHeader.test.tsx +++ b/apps/web/src/components/ui/SortableColumnHeader.test.tsx @@ -263,4 +263,62 @@ describe("SortableColumnHeader", () => { expect(container.querySelector("th")?.className).toContain("w-48"); }); }); + + describe("ARIA semantics", () => { + it("sets aria-sort='ascending' on when sorted asc", () => { + renderInTable( + , + ); + const th = document.querySelector("th"); + expect(th).toHaveAttribute("aria-sort", "ascending"); + }); + + it("sets aria-sort='descending' on when sorted desc", () => { + renderInTable( + , + ); + const th = document.querySelector("th"); + expect(th).toHaveAttribute("aria-sort", "descending"); + }); + + it("does not set aria-sort when this field is not the active sort", () => { + renderInTable( + , + ); + const th = document.querySelector("th"); + expect(th).not.toHaveAttribute("aria-sort"); + }); + + it("does not set aria-sort when sortDir is null", () => { + renderInTable( + , + ); + const th = document.querySelector("th"); + expect(th).not.toHaveAttribute("aria-sort"); + }); + }); }); diff --git a/apps/web/src/lib/csv-export.test.ts b/apps/web/src/lib/csv-export.test.ts index b2f26ee..885dd72 100644 --- a/apps/web/src/lib/csv-export.test.ts +++ b/apps/web/src/lib/csv-export.test.ts @@ -126,4 +126,21 @@ describe("generateCsv", () => { expect(lines[1]).toBe("abc"); expect(lines[2]).toBe("abc"); }); + + it("does NOT quote a cell value that contains only a carriage return (\\r is not in the escape list)", () => { + // The escapeCsvValue function checks for '\n' but not '\r'. + // A bare \r is therefore left unquoted. + const crCols = [{ header: "Field", accessor: (_r: unknown) => "line1\rline2" }]; + const csv = generateCsv([{}], crCols); + const dataLine = csv.split("\n")[1]; + // Because \r is not special-cased, the value is NOT wrapped in quotes + expect(dataLine).toBe("line1\rline2"); + }); + + it("empty columns array produces just a newline for no rows", () => { + // header = "" (no columns), body = "" (no rows) + // result = "" + "\n" + "" = "\n" + const csv = generateCsv([], []); + expect(csv).toBe("\n"); + }); }); diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts index c2b9c3c..057f611 100644 --- a/apps/web/src/lib/format.test.ts +++ b/apps/web/src/lib/format.test.ts @@ -239,3 +239,36 @@ describe("formatCents", () => { expect(formatCents(5)).toBe("0,05"); }); }); + +// --------------------------------------------------------------------------- +// edge cases +// --------------------------------------------------------------------------- +describe("edge cases", () => { + it("formatCents with NaN input — passes through toLocaleString which returns 'NaN'", () => { + // NaN == null is false, so it reaches the toLocaleString branch + // NaN / 100 is NaN; de-DE toLocaleString of NaN returns "NaN" + const result = formatCents(NaN); + expect(result).toBe("NaN"); + }); + + it("formatCents with Number.MAX_SAFE_INTEGER — no precision loss in string output", () => { + // Verify it returns a non-empty, numeric-looking string and not '-' + const result = formatCents(Number.MAX_SAFE_INTEGER); + expect(result).not.toBe("-"); + expect(result.length).toBeGreaterThan(0); + // The integer part should contain the expected leading digits (90071992547409) + expect(result).toContain("90.071.992.547.409"); + }); + + it("toDateInputValue with an invalid date string — returns 'NaN-NaN-NaN'", () => { + // new Date("not-a-date") produces an Invalid Date; getFullYear() etc. return NaN + expect(toDateInputValue("not-a-date")).toBe("NaN-NaN-NaN"); + }); + + it("formatMoney with 0 cents — returns the zero euro representation", () => { + const result = formatMoney(0); + // de-DE locale: "0 €" (with non-breaking space before €) + expect(result).toContain("0"); + expect(result).toContain("€"); + }); +}); diff --git a/apps/web/src/lib/sanitize.test.ts b/apps/web/src/lib/sanitize.test.ts index 2e2d841..da730b6 100644 --- a/apps/web/src/lib/sanitize.test.ts +++ b/apps/web/src/lib/sanitize.test.ts @@ -69,4 +69,20 @@ describe("sanitizeHtml", () => { expect(result).not.toContain("iframe"); expect(result).toContain("safe"); }); + + it("strips self-closing
tag leaving no tag remnants", () => { + const result = sanitizeHtml("line1
line2"); + expect(result).not.toContain(""); + expect(result).toContain("line1"); + expect(result).toContain("line2"); + }); + + it("strips self-closing tag with attributes leaving no tag remnants", () => { + const result = sanitizeHtml('beforeyafter'); + expect(result).not.toContain("