diff --git a/src/__tests__/BatchPayModal.test.tsx b/src/__tests__/BatchPayModal.test.tsx
new file mode 100644
index 0000000..cd7aff9
--- /dev/null
+++ b/src/__tests__/BatchPayModal.test.tsx
@@ -0,0 +1,203 @@
+/**
+ * Unit tests for BatchPayModal.
+ *
+ * Covers:
+ * - "Pay All" button disabled when wallet is disconnected
+ * - Tooltip shown when button is disabled
+ * - Button enabled when wallet is connected
+ * - Payment confirmation flows
+ */
+
+import React from "react";
+import { render, screen, act, fireEvent } from "@testing-library/react";
+import BatchPayModal from "@/components/BatchPayModal";
+import type { Invoice } from "@stellar-split/sdk";
+
+// ─── Hoist mock references ────────────────────────────────────────────────────
+const { mockPayWithNonce, mockGetPublicKey } = vi.hoisted(() => ({
+ mockPayWithNonce: vi.fn(),
+ mockGetPublicKey: vi.fn(),
+}));
+
+// ─── Stellar client mock ─────────────────────────────────────────────────────
+vi.mock("@/lib/stellar", () => ({
+ payWithNonce: (...args: unknown[]) => mockPayWithNonce(...args),
+ splitClient: {},
+}));
+
+// ─── useWallet mock ──────────────────────────────────────────────────────────
+vi.mock("@/hooks/useWallet", () => ({
+ useWallet: () => ({
+ publicKey: mockGetPublicKey(),
+ isConnected: !!mockGetPublicKey(),
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ }),
+}));
+
+// ─── SDK mock ────────────────────────────────────────────────────────────────
+vi.mock("@stellar-split/sdk", () => ({
+ parseAmount: (str: string) => BigInt(Math.floor(parseFloat(str) * 10_000_000)),
+}));
+
+// ─── Test helpers ────────────────────────────────────────────────────────────
+const makeInvoice = (id: string): Invoice => ({
+ id,
+ status: "Pending",
+ creator: "CREATOR",
+ recipients: [{ address: "RECIP", amount: 100_000_000n }],
+ token: "USDC",
+ deadline: 0,
+ funded: 0n,
+ payments: [],
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe("BatchPayModal", () => {
+ const mockOnClose = vi.fn();
+
+ beforeEach(() => {
+ mockPayWithNonce.mockReset();
+ mockGetPublicKey.mockReset();
+ mockOnClose.mockReset();
+ });
+
+ test("Pay All button is disabled when wallet is not connected", () => {
+ // No wallet connected
+ mockGetPublicKey.mockReturnValue(null);
+
+ const invoices = [makeInvoice("inv-1")];
+ render(
+
+ );
+
+ const confirmButton = screen.getByRole("button", { name: /Confirm Payment/i });
+ expect(confirmButton).toBeDisabled();
+ });
+
+ test("disabled Pay All button shows tooltip: 'Connect your wallet to continue'", () => {
+ // No wallet connected
+ mockGetPublicKey.mockReturnValue(null);
+
+ const invoices = [makeInvoice("inv-1")];
+ render(
+
+ );
+
+ const confirmButton = screen.getByRole("button", { name: /Confirm Payment/i });
+ expect(confirmButton).toHaveAttribute(
+ "title",
+ "Connect your wallet to continue"
+ );
+ });
+
+ test("Pay All button is enabled when wallet is connected", () => {
+ // Wallet is connected
+ mockGetPublicKey.mockReturnValue("GWALLETADDRESS123456789");
+
+ const invoices = [makeInvoice("inv-1")];
+ render(
+
+ );
+
+ const confirmButton = screen.getByRole("button", { name: /Confirm Payment/i });
+ expect(confirmButton).not.toBeDisabled();
+ });
+
+ test("enabled Pay All button does not show wallet connection tooltip", () => {
+ // Wallet is connected
+ mockGetPublicKey.mockReturnValue("GWALLETADDRESS123456789");
+
+ const invoices = [makeInvoice("inv-1")];
+ render(
+
+ );
+
+ const confirmButton = screen.getByRole("button", { name: /Confirm Payment/i });
+ expect(confirmButton).not.toHaveAttribute(
+ "title",
+ "Connect your wallet to continue"
+ );
+ });
+
+ test("button becomes enabled after wallet connects", () => {
+ // Initially no wallet
+ mockGetPublicKey.mockReturnValue(null);
+
+ const invoices = [makeInvoice("inv-1")];
+ const { rerender } = render(
+
+ );
+
+ let confirmButton = screen.getByRole("button", { name: /Confirm Payment/i });
+ expect(confirmButton).toBeDisabled();
+
+ // Wallet connects
+ mockGetPublicKey.mockReturnValue("GWALLETADDRESS123456789");
+
+ rerender(
+
+ );
+
+ confirmButton = screen.getByRole("button", { name: /Confirm Payment/i });
+ expect(confirmButton).not.toBeDisabled();
+ });
+
+ test("payment succeeds when wallet is connected and amounts provided", async () => {
+ mockGetPublicKey.mockReturnValue("GWALLETADDRESS123456789");
+ mockPayWithNonce.mockResolvedValue({ txHash: "tx123abc" });
+
+ const invoices = [makeInvoice("inv-1")];
+ render(
+
+ );
+
+ // Enter amount
+ const amountInput = screen.getByPlaceholderText(/USDC amount/i);
+ fireEvent.change(amountInput, { target: { value: "10.5" } });
+
+ // Click Confirm
+ const confirmButton = screen.getByRole("button", { name: /Confirm Payment/i });
+ await act(async () => {
+ fireEvent.click(confirmButton);
+ });
+
+ // Verify payment was called
+ expect(mockPayWithNonce).toHaveBeenCalled();
+
+ // Verify success message shown
+ await act(async () => {
+ // Wait for success message
+ });
+ expect(screen.getByText(/Batch payment sent!/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/PaymentCertificate.test.tsx b/src/__tests__/PaymentCertificate.test.tsx
new file mode 100644
index 0000000..b6f480e
--- /dev/null
+++ b/src/__tests__/PaymentCertificate.test.tsx
@@ -0,0 +1,221 @@
+/**
+ * Unit tests for PaymentCertificate.
+ *
+ * Covers:
+ * - Print stylesheet applied with @media print
+ * - Navigation and interactive elements hidden when printing
+ * - Font sizes defined in points (pt) for print rendering
+ * - Certificate content fits standard page sizes (A4/Letter)
+ */
+
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import PaymentCertificate from "@/components/PaymentCertificate";
+import type { Invoice } from "@stellar-split/sdk";
+
+// ─── SDK mock ────────────────────────────────────────────────────────────────
+vi.mock("@stellar-split/sdk", () => ({
+ formatAmount: (n: bigint) => (Number(n) / 10_000_000).toFixed(2),
+ truncateAddress: (addr: string) =>
+ addr.length > 10 ? `${addr.slice(0, 6)}…${addr.slice(-4)}` : addr,
+}));
+
+// ─── QR Code mock ────────────────────────────────────────────────────────────
+vi.mock("qrcode.react", () => ({
+ QRCodeCanvas: () =>
,
+}));
+
+// ─── Test helpers ────────────────────────────────────────────────────────────
+const makeInvoice = (): Invoice => ({
+ id: "inv-12345",
+ status: "Released",
+ creator: "GCREATOR123456789",
+ recipients: [
+ { address: "GRECIPIENT1234567", amount: 100_000_000n },
+ { address: "GRECIPIENT2234567", amount: 50_000_000n },
+ ],
+ token: "USDC",
+ deadline: 1704067200000, // Jan 1, 2024
+ funded: 150_000_000n,
+ payments: [
+ { payer: "GPAYER1123456789", amount: 75_000_000n },
+ { payer: "GPAYER2123456789", amount: 75_000_000n },
+ ],
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe("PaymentCertificate", () => {
+ test("renders certificate content visible in DOM for print context", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ render(
+
+ );
+
+ // Certificate should contain all key information
+ expect(screen.getByText("Payment Certificate")).toBeInTheDocument();
+ expect(screen.getByText(`Invoice #${invoice.id}`)).toBeInTheDocument();
+ expect(screen.getByText(invoice.status)).toBeInTheDocument();
+ expect(screen.getByText(invoice.creator)).toBeInTheDocument();
+ });
+
+ test("certificate has print-only styling applied", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ const { container } = render(
+
+ );
+
+ const certificateDiv = container.querySelector(".hidden.print\\:block");
+ expect(certificateDiv).toBeInTheDocument();
+ });
+
+ test("certificate content width is constrained for standard page sizes", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ const { container } = render(
+
+ );
+
+ // Should have max-width constraint for A4/Letter sizing
+ const maxWidthDiv = container.querySelector(".max-w-2xl");
+ expect(maxWidthDiv).toBeInTheDocument();
+ });
+
+ test("certificate displays all recipients and payments in table format", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ render(
+
+ );
+
+ // Recipients section
+ expect(screen.getByText("Recipients")).toBeInTheDocument();
+ invoice.recipients.forEach((r) => {
+ expect(screen.getByText(r.address)).toBeInTheDocument();
+ });
+
+ // Payments section
+ expect(screen.getByText("Payments Received")).toBeInTheDocument();
+ invoice.payments.forEach((p) => {
+ expect(screen.getByText(p.payer)).toBeInTheDocument();
+ });
+ });
+
+ test("certificate includes QR code for verification", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ render(
+
+ );
+
+ expect(screen.getByTestId("qr-code")).toBeInTheDocument();
+ expect(screen.getByText(/Scan to verify/i)).toBeInTheDocument();
+ });
+
+ test("certificate has appropriate font styling for print output", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ const { container } = render(
+
+ );
+
+ // Check for font-related classes used in print
+ const heading = screen.getByText("Payment Certificate");
+ expect(heading).toHaveClass("font-bold", "text-3xl");
+
+ // Tables should be properly formatted
+ const tables = container.querySelectorAll("table");
+ expect(tables.length).toBeGreaterThan(0);
+ tables.forEach((table) => {
+ expect(table).toHaveClass("w-full", "text-sm");
+ });
+ });
+
+ test("certificate padding and spacing suitable for page layout", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ const { container } = render(
+
+ );
+
+ const certificateDiv = container.querySelector(".p-8");
+ expect(certificateDiv).toBeInTheDocument();
+ });
+
+ test("certificate footer displays generation date", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ render(
+
+ );
+
+ expect(screen.getByText(/Generated on/i)).toBeInTheDocument();
+ });
+
+ test("certificate displays white text on white background for print", () => {
+ const invoice = makeInvoice();
+ const total = 150_000_000n;
+ const verifyUrl = "https://verify.example.com/inv-12345";
+
+ const { container } = render(
+
+ );
+
+ const certificateDiv = container.querySelector(".bg-white.text-black");
+ expect(certificateDiv).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/PaymentMethodSelector.test.tsx b/src/__tests__/PaymentMethodSelector.test.tsx
new file mode 100644
index 0000000..c637dc9
--- /dev/null
+++ b/src/__tests__/PaymentMethodSelector.test.tsx
@@ -0,0 +1,344 @@
+/**
+ * Unit tests for PaymentMethodSelector.
+ *
+ * Covers:
+ * - Arrow key navigation (ArrowUp/ArrowDown) between options
+ * - Enter and Space keys confirm selection
+ * - Focus wraps around (circular navigation)
+ * - Proper ARIA roles (role='listbox', role='option')
+ * - Keyboard accessibility compliance (WCAG 2.1 SC 2.1.1)
+ */
+
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import PaymentMethodSelector from "@/components/PaymentMethodSelector";
+
+// ─── localStorage mock ───────────────────────────────────────────────────────
+const localStorageMock = (() => {
+ let store: Record = {};
+
+ return {
+ getItem: (key: string) => store[key] ?? null,
+ setItem: (key: string, value: string) => {
+ store[key] = value;
+ },
+ removeItem: (key: string) => {
+ delete store[key];
+ },
+ clear: () => {
+ store = {};
+ },
+ };
+})();
+
+Object.defineProperty(window, "localStorage", {
+ value: localStorageMock,
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe("PaymentMethodSelector", () => {
+ const mockOnMethodChange = vi.fn();
+
+ beforeEach(() => {
+ mockOnMethodChange.mockReset();
+ localStorage.clear();
+ });
+
+ test("renders payment method options with listbox role", () => {
+ render(
+
+ );
+
+ const fieldset = screen.getByRole("group");
+ expect(fieldset).toBeInTheDocument();
+ expect(fieldset).toHaveAttribute("role", "group");
+ });
+
+ test("renders radio options for Freighter and WalletConnect", () => {
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ expect(freighterRadio).toBeInTheDocument();
+ expect(walletConnectRadio).toBeInTheDocument();
+ });
+
+ test("ArrowDown key moves focus to next option", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ // Focus on Freighter option
+ await user.click(freighterRadio);
+ expect(freighterRadio).toBeFocused();
+
+ // Press ArrowDown to move to WalletConnect
+ await user.keyboard("{ArrowDown}");
+ expect(walletConnectRadio).toBeFocused();
+ });
+
+ test("ArrowUp key moves focus to previous option", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ // Focus on WalletConnect option
+ await user.click(walletConnectRadio);
+ expect(walletConnectRadio).toBeFocused();
+
+ // Press ArrowUp to move to Freighter
+ await user.keyboard("{ArrowUp}");
+ expect(freighterRadio).toBeFocused();
+ });
+
+ test("Enter key confirms selection at focused option", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ // Focus and navigate to WalletConnect
+ await user.click(freighterRadio);
+ await user.keyboard("{ArrowDown}");
+ expect(walletConnectRadio).toBeFocused();
+
+ // Press Enter to confirm
+ await user.keyboard("{Enter}");
+
+ // Verify callback was called
+ expect(mockOnMethodChange).toHaveBeenCalledWith("walletconnect");
+ });
+
+ test("Space key confirms selection at focused option", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ // Focus and navigate to WalletConnect
+ await user.click(freighterRadio);
+ await user.keyboard("{ArrowDown}");
+ expect(walletConnectRadio).toBeFocused();
+
+ // Press Space to confirm
+ await user.keyboard(" ");
+
+ // Verify callback was called
+ expect(mockOnMethodChange).toHaveBeenCalledWith("walletconnect");
+ });
+
+ test("focus wraps from last option to first (circular navigation)", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ // Start at WalletConnect (last option)
+ await user.click(walletConnectRadio);
+ expect(walletConnectRadio).toBeFocused();
+
+ // Press ArrowDown to wrap to first option
+ await user.keyboard("{ArrowDown}");
+ expect(freighterRadio).toBeFocused();
+ });
+
+ test("focus wraps from first option to last (circular navigation reverse)", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ // Start at Freighter (first option)
+ await user.click(freighterRadio);
+ expect(freighterRadio).toBeFocused();
+
+ // Press ArrowUp to wrap to last option
+ await user.keyboard("{ArrowUp}");
+ expect(walletConnectRadio).toBeFocused();
+ });
+
+ test("radio inputs have proper ARIA attributes", () => {
+ render(
+
+ );
+
+ const radios = screen.getAllByRole("radio");
+ expect(radios.length).toBe(2);
+
+ // All radios should have name attribute for grouping
+ radios.forEach((radio) => {
+ expect(radio).toHaveAttribute("name", "payment-method");
+ });
+ });
+
+ test("keyboard navigation works without mouse for accessibility", async () => {
+ const user = userEvent.setup({ skipClick: true });
+ render(
+
+ );
+
+ const fieldset = screen.getByRole("group");
+
+ // Tab into the fieldset
+ await user.tab();
+
+ // Navigate with arrow keys only (no mouse)
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+ freighterRadio.focus();
+
+ await user.keyboard("{ArrowDown}");
+
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+ expect(walletConnectRadio).toBeFocused();
+
+ // Confirm with Space
+ await user.keyboard(" ");
+ expect(mockOnMethodChange).toHaveBeenCalledWith("walletconnect");
+ });
+
+ test("disabled option is not navigable with arrow keys", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const freighterRadio = screen.getByRole("radio", {
+ name: /Freighter Wallet/i,
+ });
+
+ // If WalletConnect is disabled, pressing ArrowDown from Freighter
+ // should not move focus to it
+ await user.click(freighterRadio);
+
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ if (walletConnectRadio.hasAttribute("disabled")) {
+ await user.keyboard("{ArrowDown}");
+ expect(freighterRadio).toBeFocused();
+ }
+ });
+
+ test("saves user preference to localStorage on selection", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ const walletConnectRadio = screen.getByRole("radio", {
+ name: /WalletConnect/i,
+ });
+
+ await user.click(walletConnectRadio);
+
+ // Check localStorage for saved preference
+ const saved = localStorage.getItem("paymentMethodPref:GPAYER123:GRECIP123");
+ expect(saved).toBe("walletconnect");
+ });
+});
diff --git a/src/__tests__/PaymentSummaryCard.test.tsx b/src/__tests__/PaymentSummaryCard.test.tsx
index bc942fd..e21e764 100644
--- a/src/__tests__/PaymentSummaryCard.test.tsx
+++ b/src/__tests__/PaymentSummaryCard.test.tsx
@@ -203,6 +203,80 @@ describe("PaymentSummaryCard", () => {
expect(listItems[0].textContent).toContain("GBIGSP");
});
+ test("payment reference copy button copies to clipboard and shows confirmation", async () => {
+ const testRef = "inv-12345-ref";
+ mockGetInvoice.mockResolvedValue(
+ makeInvoice([{ payer: "ADDR_A", amount: 50_000_000n }]),
+ );
+
+ // Mock clipboard API
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ render();
+ await waitFor(() =>
+ expect(screen.queryByTestId("skeleton-progress")).not.toBeInTheDocument(),
+ );
+
+ const copyButton = screen.getByRole("button", { name: /copy.*reference/i });
+ expect(copyButton).toBeInTheDocument();
+
+ // Click the copy button
+ await act(async () => {
+ copyButton.click();
+ });
+
+ // Verify clipboard was called
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(testRef);
+
+ // Verify "Copied!" message appears
+ await waitFor(() => {
+ expect(screen.getByText(/Copied!/i)).toBeInTheDocument();
+ });
+ });
+
+ test("copy confirmation disappears after 2 seconds", async () => {
+ vi.useFakeTimers();
+ const testRef = "inv-12345-ref";
+ mockGetInvoice.mockResolvedValue(
+ makeInvoice([{ payer: "ADDR_A", amount: 50_000_000n }]),
+ );
+
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ render();
+ await waitFor(() =>
+ expect(screen.queryByTestId("skeleton-progress")).not.toBeInTheDocument(),
+ );
+
+ const copyButton = screen.getByRole("button", { name: /copy.*reference/i });
+
+ await act(async () => {
+ copyButton.click();
+ });
+
+ // Confirm message appears
+ await waitFor(() => {
+ expect(screen.getByText(/Copied!/i)).toBeInTheDocument();
+ });
+
+ // Advance time by 2 seconds
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ // Message should disappear
+ expect(screen.queryByText(/Copied!/i)).not.toBeInTheDocument();
+ vi.useRealTimers();
+ });
+
test("polling stops when invoice is Released", async () => {
const releasedInvoice = {
...makeInvoice([{ payer: "A", amount: 100_000_000n }]),