Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/__tests__/AccessibilityAudit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import AccessibilityAuditPanel from "@/components/AccessibilityAuditPanel";
import { auditColorPairs, auditDocument } from "@/lib/accessibilityAudit";

function html(markup: string): HTMLElement {
const root = document.createElement("div");
root.innerHTML = markup;
document.body.appendChild(root);
return root;
}

afterEach(() => {
document.body.innerHTML = "";
});

describe("auditDocument", () => {
it("reports nothing for accessible markup", () => {
const root = html(`
<h1>Title</h1><h2>Section</h2>
<img src="a.png" alt="" />
<button>Save</button>
<button aria-label="Close"></button>
<a href="/x"><img src="i.png" alt="Home" /></a>
<label for="n">Name</label><input id="n" />
<label>Email <input /></label>
<input type="hidden" />
`);
expect(auditDocument(root)).toEqual([]);
});

it("flags missing alt, unnamed controls, unlabeled fields and heading jumps", () => {
const root = html(`
<h1>Title</h1><h3>Skipped</h3>
<img src="a.png" />
<button></button>
<a href="/x"></a>
<input id="orphan" />
<select></select>
`);
const rules = auditDocument(root).map((i) => i.rule);
expect(rules.filter((r) => r === "img-alt")).toHaveLength(1);
expect(rules.filter((r) => r === "control-name")).toHaveLength(2);
expect(rules.filter((r) => r === "form-label")).toHaveLength(2);
expect(rules.filter((r) => r === "heading-order")).toHaveLength(1);
});

it("accepts aria-labelledby references", () => {
const root = html(`<span id="l">Amount</span><input aria-labelledby="l" />`);
expect(auditDocument(root)).toEqual([]);
});
});

describe("auditColorPairs", () => {
it("flags low contrast, honours large text and rejects invalid colors", () => {
const issues = auditColorPairs([
{ label: "ok", foreground: "#000000", background: "#ffffff" },
{ label: "low", foreground: "#aaaaaa", background: "#ffffff" },
{ label: "large-ok", foreground: "#767676", background: "#ffffff", largeText: true },
{ label: "bad", foreground: "nope", background: "#ffffff" },
]);
expect(issues.map((i) => i.element)).toEqual(["low", "bad"]);
expect(issues[0].message).toContain("below the required 4.5:1");
});
});

describe("AccessibilityAuditPanel", () => {
it("runs the audit on demand", () => {
render(
<div>
<img src="x.png" />
<AccessibilityAuditPanel />
</div>,
);
expect(screen.queryByText(/issue/)).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Run audit"));
expect(screen.getByText("1 issue found")).toBeInTheDocument();
expect(screen.getByText("Image is missing an alt attribute.")).toBeInTheDocument();
});
});
72 changes: 72 additions & 0 deletions src/__tests__/CommunityEventsCalendar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import CommunityEventsCalendar from "@/components/creator/CommunityEventsCalendar";
import {
filterEvents,
groupEventsByDay,
monthGrid,
toggleRsvp,
upcomingEvents,
type CommunityEvent,
} from "@/lib/creatorEvents";

const events: CommunityEvent[] = [
{ id: "b", title: "Invoice meetup", type: "meetup", startsAt: "2026-03-20T15:00:00", location: "Lagos" },
{ id: "a", title: "Splits workshop", type: "workshop", startsAt: "2026-03-05T10:00:00" },
{ id: "c", title: "April webinar", type: "webinar", startsAt: "2026-04-02T10:00:00" },
{ id: "bad", title: "Broken", type: "meetup", startsAt: "not-a-date" },
];

describe("creatorEvents", () => {
it("sorts, filters by type and drops invalid dates", () => {
expect(filterEvents(events).map((e) => e.id)).toEqual(["a", "b", "c"]);
expect(filterEvents(events, "meetup").map((e) => e.id)).toEqual(["b"]);
});

it("returns only upcoming events", () => {
expect(upcomingEvents(events, new Date("2026-03-10T00:00:00")).map((e) => e.id)).toEqual(["b", "c"]);
});

it("groups events by local day", () => {
const groups = groupEventsByDay(events);
expect(groups.get("2026-03-05")?.map((e) => e.id)).toEqual(["a"]);
});

it("builds whole-week month grids", () => {
const grid = monthGrid(2026, 2); // March 2026 starts on a Sunday
expect(grid.length % 7).toBe(0);
expect(grid[0]?.getDate()).toBe(1);
expect(grid.filter(Boolean)).toHaveLength(31);
});

it("toggles RSVPs without mutating the input", () => {
const empty = new Set<string>();
const one = toggleRsvp(empty, "a");
expect(one.has("a")).toBe(true);
expect(empty.size).toBe(0);
expect(toggleRsvp(one, "a").has("a")).toBe(false);
});
});

describe("CommunityEventsCalendar", () => {
it("shows the month's events, filters by type and navigates months", () => {
render(<CommunityEventsCalendar events={events} initialDate={new Date(2026, 2, 1)} />);
expect(screen.getByRole("heading", { name: "March 2026" })).toBeInTheDocument();
expect(screen.getAllByText("Splits workshop").length).toBeGreaterThan(0);

fireEvent.change(screen.getByLabelText("Event type"), { target: { value: "meetup" } });
expect(screen.queryByText(/Splits workshop/)).not.toBeInTheDocument();

fireEvent.click(screen.getByLabelText("Next month"));
expect(screen.getByRole("heading", { name: "April 2026" })).toBeInTheDocument();
expect(screen.getByText("No events this month.")).toBeInTheDocument();
});

it("toggles RSVP and reports it", () => {
const onRsvpChange = vi.fn();
render(<CommunityEventsCalendar events={events} initialDate={new Date(2026, 2, 1)} onRsvpChange={onRsvpChange} />);
fireEvent.click(screen.getAllByText("RSVP")[0]);
expect(onRsvpChange).toHaveBeenLastCalledWith(["a"]);
expect(screen.getByText("Going")).toHaveAttribute("aria-pressed", "true");
});
});
64 changes: 64 additions & 0 deletions src/__tests__/InvoiceSecurityDashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import InvoiceSecurityDashboard from "@/components/security/InvoiceSecurityDashboard";
import { levelForScore, summarizeInvoiceSecurity, type InvoiceSecurityEvent } from "@/lib/invoiceSecurity";

const events: InvoiceSecurityEvent[] = [
{ id: "1", invoiceId: "INV-1", kind: "failed_auth", timestamp: 1 },
{ id: "2", invoiceId: "INV-1", kind: "large_payment", timestamp: 2 },
{ id: "3", invoiceId: "INV-2", kind: "export", timestamp: 3 },
];

describe("invoiceSecurity", () => {
it("scores 100 with no events", () => {
const summary = summarizeInvoiceSecurity([]);
expect(summary.score).toBe(100);
expect(summary.level).toBe("good");
expect(summary.riskiestInvoices).toEqual([]);
});

it("weights events, counts by kind and ranks invoices by risk", () => {
const summary = summarizeInvoiceSecurity(events);
expect(summary.score).toBe(100 - 5 - 10 - 1);
expect(summary.countsByKind.failed_auth).toBe(1);
expect(summary.riskiestInvoices).toEqual([
{ invoiceId: "INV-1", risk: 15, events: 2 },
{ invoiceId: "INV-2", risk: 1, events: 1 },
]);
});

it("never drops below zero and maps score to a level", () => {
const many = Array.from({ length: 50 }, (_, i) => ({
id: String(i),
invoiceId: "INV-X",
kind: "large_payment" as const,
timestamp: i,
}));
const summary = summarizeInvoiceSecurity(many);
expect(summary.score).toBe(0);
expect(summary.level).toBe("at_risk");
expect(levelForScore(79)).toBe("attention");
expect(levelForScore(80)).toBe("good");
});

it("includes suspicious-activity alerts in the score", () => {
const activity = Array.from({ length: 6 }, (_, i) => ({ id: `e${i}`, actor: "GA", amount: 10 + i, timestamp: i * 1000 }));
const summary = summarizeInvoiceSecurity([], activity);
expect(summary.alerts.length).toBeGreaterThan(0);
expect(summary.score).toBeLessThan(100);
});
});

describe("InvoiceSecurityDashboard", () => {
it("renders the score, counts and riskiest invoices", () => {
render(<InvoiceSecurityDashboard events={events} />);
expect(screen.getByRole("status")).toHaveTextContent("84");
expect(screen.getByText("Failed sign-ins")).toBeInTheDocument();
expect(screen.getByText("INV-1")).toBeInTheDocument();
});

it("shows an empty state", () => {
render(<InvoiceSecurityDashboard events={[]} />);
expect(screen.getByText("No security events recorded.")).toBeInTheDocument();
});
});
54 changes: 54 additions & 0 deletions src/__tests__/ResourceLibrary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import ResourceLibrary from "@/components/creator/ResourceLibrary";
import {
DEFAULT_RESOURCES,
filterResources,
loadBookmarks,
saveBookmarks,
toggleBookmark,
} from "@/lib/creatorResources";

beforeEach(() => window.localStorage.clear());

describe("creatorResources", () => {
it("filters by query (title, description, tags) and category", () => {
expect(filterResources(DEFAULT_RESOURCES, { query: "TAX" }).map((r) => r.id)).toEqual(["tax-export"]);
expect(filterResources(DEFAULT_RESOURCES, { category: "template" }).map((r) => r.id)).toEqual(["invoice-templates"]);
expect(filterResources(DEFAULT_RESOURCES, { category: "all" })).toHaveLength(DEFAULT_RESOURCES.length);
});

it("filters to bookmarks and toggles immutably", () => {
const none = new Set<string>();
const one = toggleBookmark(none, "rate-card");
expect(none.size).toBe(0);
expect(filterResources(DEFAULT_RESOURCES, { bookmarkedOnly: true }, one).map((r) => r.id)).toEqual(["rate-card"]);
expect(toggleBookmark(one, "rate-card").size).toBe(0);
});

it("round-trips bookmarks and survives corrupt storage", () => {
saveBookmarks(new Set(["a", "b"]));
expect([...loadBookmarks()].sort()).toEqual(["a", "b"]);
window.localStorage.setItem("creator-resource-bookmarks", "{not json");
expect(loadBookmarks().size).toBe(0);
});
});

describe("ResourceLibrary", () => {
it("searches, and bookmarks persist", () => {
render(<ResourceLibrary />);
fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "rate" } });
expect(screen.getByText("Rate card builder")).toBeInTheDocument();
expect(screen.queryByText("Tax export")).not.toBeInTheDocument();

fireEvent.click(screen.getByLabelText("Bookmark Rate card builder"));
expect(screen.getByText("Bookmarked")).toBeInTheDocument();
expect([...loadBookmarks()]).toEqual(["rate-card"]);
});

it("shows an empty state", () => {
render(<ResourceLibrary />);
fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "zzz-nothing" } });
expect(screen.getByText("No resources match.")).toBeInTheDocument();
});
});
10 changes: 10 additions & 0 deletions src/app/creator/events/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import CommunityEventsCalendar from "@/components/creator/CommunityEventsCalendar";

export default function CreatorEventsPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-4 text-2xl font-semibold">Community events</h1>
<CommunityEventsCalendar events={[]} />
</main>
);
}
10 changes: 10 additions & 0 deletions src/app/creator/resources/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import ResourceLibrary from "@/components/creator/ResourceLibrary";

export default function CreatorResourcesPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-4 text-2xl font-semibold">Creator resources</h1>
<ResourceLibrary />
</main>
);
}
10 changes: 10 additions & 0 deletions src/app/security/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import InvoiceSecurityDashboard from "@/components/security/InvoiceSecurityDashboard";

export default function InvoiceSecurityPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-4 text-2xl font-semibold">Invoice security</h1>
<InvoiceSecurityDashboard events={[]} />
</main>
);
}
3 changes: 3 additions & 0 deletions src/app/settings/accessibility/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { Contrast, FontScale } from "@/contexts/AccessibilityContext";
import { useAccessibility } from "@/contexts/AccessibilityContext";
import AccessibilityAuditPanel from "@/components/AccessibilityAuditPanel";

const FONT_SCALE_OPTIONS: Array<{
value: FontScale;
Expand Down Expand Up @@ -146,6 +147,8 @@ export default function AccessibilitySettingsPage() {
})}
</div>
</section>

<AccessibilityAuditPanel />
</div>
</main>
);
Expand Down
46 changes: 46 additions & 0 deletions src/components/AccessibilityAuditPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import { useState } from "react";
import { auditDocument, type A11yIssue } from "@/lib/accessibilityAudit";

/** Runs the accessibility audit over the current page and lists what it finds. */
export default function AccessibilityAuditPanel() {
const [issues, setIssues] = useState<A11yIssue[] | null>(null);

return (
<section aria-labelledby="audit-heading" className="border-t border-gray-700 pt-8">
<h2 id="audit-heading" className="mb-2 text-lg font-semibold">
Accessibility audit
</h2>
<p className="mb-4 text-sm text-gray-400">
Check this page for missing alt text, unlabeled controls and fields, and skipped heading levels.
</p>
<button
type="button"
className="rounded border border-gray-600 px-3 py-2 text-sm"
onClick={() => setIssues(auditDocument(document.body))}
>
Run audit
</button>

<div role="status" aria-live="polite" className="mt-4">
{issues !== null && issues.length === 0 && <p className="text-sm text-green-400">No issues found.</p>}
{issues !== null && issues.length > 0 && (
<>
<p className="mb-2 text-sm">
{issues.length} issue{issues.length === 1 ? "" : "s"} found
</p>
<ul className="space-y-2">
{issues.map((issue, i) => (
<li key={i} className="rounded border border-gray-700 p-3 text-sm">
<p className="font-medium">{issue.message}</p>
<code className="break-all text-xs text-gray-400">{issue.element}</code>
</li>
))}
</ul>
</>
)}
</div>
</section>
);
}
Loading