diff --git a/src/__tests__/BatchPayQueue.test.tsx b/src/__tests__/BatchPayQueue.test.tsx new file mode 100644 index 0000000..67619d0 --- /dev/null +++ b/src/__tests__/BatchPayQueue.test.tsx @@ -0,0 +1,239 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import BatchPayQueue from "@/components/BatchPayQueue"; +import type { Invoice } from "@stellar-split/sdk"; + +vi.mock("@stellar-split/sdk", () => ({ + formatAmount: (value: bigint) => (Number(value) / 10_000_000).toFixed(2), + parseAmount: (value: string) => { + const num = Number(value); + return isNaN(num) ? 0n : BigInt(Math.round(num * 10_000_000)); + }, +})); + +const SCALE = 10_000_000n; + +const createMockInvoice = (id: string, status: string = "Pending"): Invoice => ({ + id, + creator: "GCREATOR", + recipients: [ + { address: "GRECIPIENT1", amount: 50n * SCALE }, + { address: "GRECIPIENT2", amount: 50n * SCALE }, + ], + token: "CUSDC", + deadline: 0, + funded: 0n, + status, + payments: [], +}); + +describe("Issue #597: BatchPayQueue error messages", () => { + it("should render queue items with empty state message when queue is empty", () => { + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + + render( + + ); + + expect( + screen.getByText(/search for invoices above to add them/i) + ).toBeInTheDocument(); + }); + + it("should display queue items with invoice details", () => { + const invoice = createMockInvoice("inv-123"); + const queue = [{ invoice, amount: "100" }]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + + render( + + ); + + const invoiceText = screen.getAllByText(/invoice #inv-123/i)[0]; + expect(invoiceText).toBeInTheDocument(); + expect(screen.getByText(/status: pending/i)).toBeInTheDocument(); + }); + + it("should allow changing amount for queue items", async () => { + const invoice = createMockInvoice("inv-456"); + const queue = [{ invoice, amount: "" }]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + + render( + + ); + + const amountInput = screen.getByPlaceholderText(/amount \(usdc\)/i) as HTMLInputElement; + fireEvent.change(amountInput, { target: { value: "75" } }); + + expect(onAmountChange).toHaveBeenCalledWith("inv-456", "75"); + }); + + it("should allow removing queue items", async () => { + const invoice = createMockInvoice("inv-789"); + const queue = [{ invoice, amount: "100" }]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + + render( + + ); + + const removeButton = screen.getByLabelText(/remove invoice/i); + fireEvent.click(removeButton); + + expect(onRemove).toHaveBeenCalledWith("inv-789"); + }); + + it("should calculate and display running total", () => { + const invoice1 = createMockInvoice("inv-1"); + const invoice2 = createMockInvoice("inv-2"); + const queue = [ + { invoice: invoice1, amount: "50" }, + { invoice: invoice2, amount: "75" }, + ]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + + render( + + ); + + expect(screen.getByText(/running total/i)).toBeInTheDocument(); + expect(screen.getByText(/125 usdc/i)).toBeInTheDocument(); + }); + + it("should handle drag and drop reordering", async () => { + const invoice1 = createMockInvoice("inv-1"); + const invoice2 = createMockInvoice("inv-2"); + const queue = [ + { invoice: invoice1, amount: "50" }, + { invoice: invoice2, amount: "75" }, + ]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + const onReorder = vi.fn(); + + const { container } = render( + + ); + + const firstItem = container.querySelector('[draggable="true"]'); + if (firstItem) { + fireEvent.dragStart(firstItem); + const items = container.querySelectorAll('[draggable="true"]'); + if (items.length > 1) { + fireEvent.dragOver(items[1]); + fireEvent.drop(items[1]); + } + } + + await waitFor(() => { + expect(onReorder).toHaveBeenCalled(); + }); + }); + + it("should support keyboard reordering with Alt+ArrowUp", async () => { + const invoice1 = createMockInvoice("inv-1"); + const invoice2 = createMockInvoice("inv-2"); + const queue = [ + { invoice: invoice1, amount: "50" }, + { invoice: invoice2, amount: "75" }, + ]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + const onReorder = vi.fn(); + + const { container } = render( + + ); + + const items = container.querySelectorAll('[draggable="true"]'); + if (items.length > 1) { + fireEvent.keyDown(items[1], { key: "ArrowUp", altKey: true }); + expect(onReorder).toHaveBeenCalled(); + } + }); + + it("should support keyboard reordering with Alt+ArrowDown", async () => { + const invoice1 = createMockInvoice("inv-1"); + const invoice2 = createMockInvoice("inv-2"); + const queue = [ + { invoice: invoice1, amount: "50" }, + { invoice: invoice2, amount: "75" }, + ]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + const onReorder = vi.fn(); + + const { container } = render( + + ); + + const items = container.querySelectorAll('[draggable="true"]'); + if (items.length > 1) { + fireEvent.keyDown(items[0], { key: "ArrowDown", altKey: true }); + expect(onReorder).toHaveBeenCalled(); + } + }); + + it("should handle NaN amounts in running total calculation", () => { + const invoice1 = createMockInvoice("inv-1"); + const invoice2 = createMockInvoice("inv-2"); + const queue = [ + { invoice: invoice1, amount: "50" }, + { invoice: invoice2, amount: "" }, + ]; + const onAmountChange = vi.fn(); + const onRemove = vi.fn(); + + render( + + ); + + expect(screen.getByText(/50 usdc/i)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/QRModalDownload.test.tsx b/src/__tests__/QRModalDownload.test.tsx new file mode 100644 index 0000000..e6d9e71 --- /dev/null +++ b/src/__tests__/QRModalDownload.test.tsx @@ -0,0 +1,366 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import QRModal from "@/components/QRModal"; + +vi.mock("@/lib/freighter", () => ({ + isWalletConnected: vi.fn().mockResolvedValue(false), +})); + +vi.mock("@/components/FocusTrap", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("qrcode.react", () => ({ + QRCodeCanvas: ({ value }: { value: string }) => ( + + ), +})); + +describe("Issue #600: QRModal Download QR Code", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("should render QR modal when open is true", () => { + const onClose = vi.fn(); + + render( + + ); + + expect(screen.getByText(/scan to connect/i)).toBeInTheDocument(); + }); + + it("should render QR code canvas", () => { + const onClose = vi.fn(); + const testUri = "wc:test-uri-123"; + + render( + + ); + + expect(screen.getByTestId(`qr-code-${testUri}`)).toBeInTheDocument(); + }); + + it("should display the URI as plain text", () => { + const testUri = "wc:1234567890abcdef"; + const onClose = vi.fn(); + + render( + + ); + + expect(screen.getByText(testUri)).toBeInTheDocument(); + }); + + it("should render Copy URI button", () => { + const onClose = vi.fn(); + + render( + + ); + + expect(screen.getByRole("button", { name: /copy uri/i })).toBeInTheDocument(); + }); + + it("should allow copying URI to clipboard", async () => { + const testUri = "wc:test-uri"; + const onClose = vi.fn(); + const onCopied = vi.fn(); + + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + + Object.defineProperty(navigator, "clipboard", { + value: mockClipboard, + configurable: true, + }); + + render( + + ); + + const copyButton = screen.getByRole("button", { name: /copy uri/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalledWith(testUri); + }); + }); + + it("should call onCopied callback after copying URI", async () => { + const testUri = "wc:test-uri"; + const onClose = vi.fn(); + const onCopied = vi.fn(); + + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + + Object.defineProperty(navigator, "clipboard", { + value: mockClipboard, + configurable: true, + }); + + render( + + ); + + const copyButton = screen.getByRole("button", { name: /copy uri/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(onCopied).toHaveBeenCalled(); + }); + }); + + it("should render Dismiss button", () => { + const onClose = vi.fn(); + + render( + + ); + + const dismissButtons = screen.getAllByRole("button", { name: /dismiss/i }); + expect(dismissButtons.length).toBeGreaterThan(0); + }); + + it("should close modal when Dismiss button is clicked", async () => { + const onClose = vi.fn(); + + render( + + ); + + const dismissButton = screen.getAllByRole("button", { name: /dismiss/i })[0]; + fireEvent.click(dismissButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it("should close modal when close button in header is clicked", async () => { + const onClose = vi.fn(); + + render( + + ); + + const closeButton = screen.getByLabelText(/close qr modal/i); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it("should not render when open is false", () => { + const onClose = vi.fn(); + + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should have proper ARIA attributes", () => { + const onClose = vi.fn(); + + const { container } = render( + + ); + + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("should poll wallet connection status", async () => { + const onConnected = vi.fn(); + const onClose = vi.fn(); + + const { isWalletConnected } = await import("@/lib/freighter"); + + render( + + ); + + vi.advanceTimersByTime(2000); + + await waitFor(() => { + expect(isWalletConnected).toHaveBeenCalled(); + }); + }); + + it("should call onConnected when wallet becomes connected", async () => { + const onConnected = vi.fn(); + const onClose = vi.fn(); + + const { isWalletConnected } = await import("@/lib/freighter"); + vi.mocked(isWalletConnected).mockResolvedValueOnce(true); + + render( + + ); + + vi.advanceTimersByTime(2000); + + await waitFor(() => { + expect(onConnected).toHaveBeenCalled(); + }); + }); + + it("should stop polling when modal is closed", async () => { + const onConnected = vi.fn(); + const onClose = vi.fn(); + + const { isWalletConnected } = await import("@/lib/freighter"); + vi.mocked(isWalletConnected).mockResolvedValue(true); + + const { rerender } = render( + + ); + + vi.advanceTimersByTime(2000); + + rerender( + + ); + + const callCountAfterClose = vi.mocked(isWalletConnected).mock.calls.length; + + vi.advanceTimersByTime(2000); + + const callCountAfterWait = vi.mocked(isWalletConnected).mock.calls.length; + + expect(callCountAfterWait).toBe(callCountAfterClose); + }); + + it("should respect custom polling interval", async () => { + const onConnected = vi.fn(); + const onClose = vi.fn(); + + const { isWalletConnected } = await import("@/lib/freighter"); + vi.mocked(isWalletConnected).mockResolvedValue(false); + + render( + + ); + + vi.advanceTimersByTime(500); + + await waitFor(() => { + expect(vi.mocked(isWalletConnected).mock.calls.length).toBeGreaterThan(0); + }); + }); + + it("should display heading with scan instruction", () => { + const onClose = vi.fn(); + + render( + + ); + + expect(screen.getByText(/scan to connect/i)).toBeInTheDocument(); + }); + + it("should have visual styling for draggable QR container", () => { + const onClose = vi.fn(); + + const { container } = render( + + ); + + const qrContainer = container.querySelector(".bg-white.rounded-xl"); + expect(qrContainer).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/TxConfirmModal.test.tsx b/src/__tests__/TxConfirmModal.test.tsx new file mode 100644 index 0000000..85d61b5 --- /dev/null +++ b/src/__tests__/TxConfirmModal.test.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import TxConfirmModal from "@/components/TxConfirmModal"; + +vi.mock("@/components/FocusTrap", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe("Issue #598: TxConfirmModal with Stellar Expert link", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should render transaction confirmation message", () => { + const onClose = vi.fn(); + + render( + + ); + + expect(screen.getByText(/✓ payment confirmed/i)).toBeInTheDocument(); + expect(screen.getByText("Transaction hash")).toBeInTheDocument(); + }); + + it("should display transaction hash", () => { + const txHash = "1234567890abcdef"; + const onClose = vi.fn(); + + render( + + ); + + expect(screen.getByText(txHash)).toBeInTheDocument(); + }); + + it("should render link to Stellar Expert with proper URL structure", () => { + const txHash = "abc123hash"; + const onClose = vi.fn(); + + render( + + ); + + const link = screen.getByRole("link", { + name: /view on stellar expert/i, + }); + expect(link).toHaveAttribute("href"); + const href = link.getAttribute("href"); + expect(href).toContain("stellar.expert/explorer"); + expect(href).toContain(`tx/${txHash}`); + }); + + it("should open Stellar Expert link in new tab with security attributes", () => { + const txHash = "abc123hash"; + const onClose = vi.fn(); + + render( + + ); + + const link = screen.getByRole("link", { + name: /view on stellar expert/i, + }); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should allow copying transaction hash", async () => { + const txHash = "abcdef123456"; + const onClose = vi.fn(); + + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + + Object.defineProperty(navigator, "clipboard", { + value: mockClipboard, + configurable: true, + }); + + render( + + ); + + const copyButton = screen.getByRole("button", { name: /copy/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalledWith(txHash); + }); + }); + + it("should show 'Copied!' feedback after copying", async () => { + const txHash = "abcdef123456"; + const onClose = vi.fn(); + + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + + Object.defineProperty(navigator, "clipboard", { + value: mockClipboard, + configurable: true, + }); + + vi.useFakeTimers(); + + render( + + ); + + const copyButton = screen.getByRole("button", { name: /copy/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(screen.getByText("Copied!")).toBeInTheDocument(); + }); + + vi.advanceTimersByTime(2001); + + await waitFor(() => { + expect(screen.getByText("Copy")).toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it("should close modal when close button is clicked", async () => { + const onClose = vi.fn(); + + render( + + ); + + const closeButton = screen.getByLabelText("Close"); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it("should close modal when clicking overlay background", async () => { + const onClose = vi.fn(); + + const { container } = render( + + ); + + const overlayContainer = container.querySelector(".fixed.inset-0"); + if (overlayContainer) { + fireEvent.click(overlayContainer); + } + + expect(onClose).toHaveBeenCalled(); + }); + + it("should render success indicator with green checkmark", () => { + const onClose = vi.fn(); + + render( + + ); + + const title = screen.getByText(/✓ payment confirmed/i); + expect(title).toHaveClass("text-green-400"); + }); + + it("should display the correct action text in confirmation message", () => { + const onClose = vi.fn(); + + render( + + ); + + expect(screen.getByText(/✓ withdrawal confirmed/i)).toBeInTheDocument(); + }); + + it("should be accessible with proper ARIA attributes", () => { + const onClose = vi.fn(); + + const { container } = render( + + ); + + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-labelledby", "tx-modal-title"); + }); +}); diff --git a/src/__tests__/WalletErrorModal.test.tsx b/src/__tests__/WalletErrorModal.test.tsx new file mode 100644 index 0000000..444ee21 --- /dev/null +++ b/src/__tests__/WalletErrorModal.test.tsx @@ -0,0 +1,321 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WalletErrorModal from "@/components/WalletErrorModal"; + +describe("Issue #599: WalletErrorModal retry functionality", () => { + it("should not render when errorType is null", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should render not installed error state", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + expect(screen.getByText(/freighter not installed/i)).toBeInTheDocument(); + expect( + screen.getByText(/freighter is a browser extension/i) + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /install freighter/i }) + ).toBeInTheDocument(); + }); + + it("should render locked wallet error state", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + expect(screen.getByText(/freighter is locked/i)).toBeInTheDocument(); + expect( + screen.getByText(/your freighter wallet is locked/i) + ).toBeInTheDocument(); + }); + + it("should render locked wallet error with Try Again button", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + const tryAgainButton = screen.getByRole("button", { name: /try again/i }); + expect(tryAgainButton).toBeInTheDocument(); + }); + + it("should call onRetry when Try Again button is clicked", async () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + const tryAgainButton = screen.getByRole("button", { name: /try again/i }); + fireEvent.click(tryAgainButton); + + expect(onRetry).toHaveBeenCalled(); + }); + + it("should render network mismatch error state", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + expect(screen.getByText(/wrong network/i)).toBeInTheDocument(); + const mainnetTexts = screen.getAllByText(/mainnet/i); + expect(mainnetTexts.length).toBeGreaterThan(0); + }); + + it("should render network mismatch error with Got it button", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + const gotItButton = screen.getByRole("button", { name: /got it/i }); + expect(gotItButton).toBeInTheDocument(); + }); + + it("should call onDismiss when Dismiss button is clicked", async () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + const dismissButton = screen.getByRole("button", { name: /cancel/i }); + fireEvent.click(dismissButton); + + expect(onDismiss).toHaveBeenCalled(); + }); + + it("should dismiss modal when Escape key is pressed", async () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onDismiss).toHaveBeenCalled(); + }); + + it("should dismiss modal when clicking on overlay background", async () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + const { container } = render( + + ); + + const overlayBackground = container.querySelector(".fixed.inset-0"); + if (overlayBackground) { + fireEvent.click(overlayBackground, { + target: overlayBackground, + currentTarget: overlayBackground, + }); + } + + expect(onDismiss).toHaveBeenCalled(); + }); + + it("should have proper ARIA attributes for accessibility", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + const { container } = render( + + ); + + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-labelledby", "wallet-error-title"); + }); + + it("should focus first interactive element when error is shown", async () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + await waitFor(() => { + const firstButton = screen.getByRole("button", { name: /try again/i }); + expect(firstButton).toHaveFocus(); + }); + }); + + it("should restore focus when error is cleared", async () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + const { rerender } = render( + + ); + + const previouslyFocused = document.activeElement; + + rerender( + + ); + + await waitFor(() => { + expect(document.activeElement).not.toBe(previouslyFocused); + }); + }); + + it("should use default expectedNetwork when not provided", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + const testnetTexts = screen.getAllByText(/testnet/i); + expect(testnetTexts.length).toBeGreaterThan(0); + }); + + it("should use custom expectedNetwork when provided", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + const customChainTexts = screen.getAllByText(/custom chain/i); + expect(customChainTexts.length).toBeGreaterThan(0); + }); + + it("should display install link for not_installed error", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + const installLink = screen.getByRole("link", { + name: /install freighter/i, + }); + expect(installLink).toHaveAttribute( + "href", + "https://www.freighter.app/" + ); + expect(installLink).toHaveAttribute("target", "_blank"); + expect(installLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should display instructions for locked wallet", () => { + const onDismiss = vi.fn(); + const onRetry = vi.fn(); + + render( + + ); + + expect(screen.getByText(/click the freighter icon/i)).toBeInTheDocument(); + expect(screen.getByText(/enter your freighter password/i)).toBeInTheDocument(); + expect(screen.getByText(/return here and click/i)).toBeInTheDocument(); + }); +});