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 }]),