diff --git a/src/__tests__/PayPreviewButton.test.tsx b/src/__tests__/PayPreviewButton.test.tsx new file mode 100644 index 0000000..b9d236d --- /dev/null +++ b/src/__tests__/PayPreviewButton.test.tsx @@ -0,0 +1,208 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter } from "next/navigation"; +import PayPreviewButton from "@/components/PayPreviewButton"; + +vi.mock("next/navigation"); +vi.mock("@/lib/freighter"); +vi.mock("@/hooks/useNetworkFeeBreakdown", () => ({ + useNetworkFeeBreakdown: vi.fn(), +})); + +const mockPush = vi.fn(); +(useRouter as any).mockReturnValue({ + push: mockPush, +}); + +import { getFreighterPublicKey } from "@/lib/freighter"; +import { useNetworkFeeBreakdown } from "@/hooks/useNetworkFeeBreakdown"; + +describe("PayPreviewButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPush.mockClear(); + }); + + test("renders pay button when invoice is Pending", () => { + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + render( + + ); + expect(screen.getByRole("button", { name: /Pay This Invoice/ })).toBeInTheDocument(); + }); + + test("shows message when invoice is not Pending", () => { + render( + + ); + expect(screen.getByText(/This invoice is released/i)).toBeInTheDocument(); + }); + + test("navigates to pay page when button clicked and wallet connected", async () => { + const user = userEvent.setup(); + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + render( + + ); + + const button = screen.getByRole("button", { name: /Pay This Invoice/ }); + await user.click(button); + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/pay/inv-1"); + }); + }); + + test("prompts wallet connection when not connected", async () => { + const user = userEvent.setup(); + vi.mocked(getFreighterPublicKey) + .mockRejectedValueOnce(new Error("No wallet")) + .mockResolvedValueOnce("GKEY123"); + + render( + + ); + + expect(screen.getByText(/Requires Freighter wallet extension/i)).toBeInTheDocument(); + + const button = screen.getByRole("button", { name: /Pay This Invoice/ }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByText(/Connecting wallet/i)).toBeInTheDocument(); + }); + }); + + test("displays error when wallet connection fails", async () => { + const user = userEvent.setup(); + vi.mocked(getFreighterPublicKey).mockRejectedValue( + new Error("Connection failed") + ); + + render( + + ); + + const button = screen.getByRole("button", { name: /Pay This Invoice/ }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + /Could not connect wallet/i + ); + }); + }); + + test("displays estimated network fee when available", async () => { + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + vi.mocked(useNetworkFeeBreakdown).mockReturnValue({ + fee: 100n, + loading: false, + error: null, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Est\. fee:/i)).toBeInTheDocument(); + }); + }); + + test("shows skeleton loader while fee is loading", async () => { + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + vi.mocked(useNetworkFeeBreakdown).mockReturnValue({ + fee: null, + loading: true, + error: null, + }); + + const { container } = render( + + ); + + await waitFor(() => { + const skeleton = container.querySelector("[data-testid='fee-skeleton']"); + expect(skeleton).toBeInTheDocument(); + }); + }); + + test("hides fee display when fetch fails", async () => { + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + vi.mocked(useNetworkFeeBreakdown).mockReturnValue({ + fee: null, + loading: false, + error: new Error("Network error"), + }); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByText(/Est\. fee:/i)).not.toBeInTheDocument(); + }); + }); + + test("formats fee with XLM and USD equivalent", async () => { + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + vi.mocked(useNetworkFeeBreakdown).mockReturnValue({ + fee: 100n, + loading: false, + error: null, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/0\.00001 XLM ≈ \$0\.00/)).toBeInTheDocument(); + }); + }); + + test("button shows connecting state during wallet connection", async () => { + const user = userEvent.setup(); + vi.mocked(getFreighterPublicKey).mockImplementationOnce( + () => new Promise(resolve => setTimeout(() => resolve("GKEY123"), 100)) + ); + + render( + + ); + + const button = screen.getByRole("button", { name: /Pay This Invoice/ }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Connecting wallet/i })).toBeInTheDocument(); + }); + }); + + test("calls getFreighterPublicKey on mount", async () => { + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + + render( + + ); + + await waitFor(() => { + expect(getFreighterPublicKey).toHaveBeenCalled(); + }); + }); + + test("does not show Freighter requirement message when wallet is connected", async () => { + vi.mocked(getFreighterPublicKey).mockResolvedValue("GKEY123"); + + render( + + ); + + await waitFor(() => { + expect( + screen.queryByText(/Requires Freighter wallet extension/i) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/PaymentBreakdownModal.test.tsx b/src/__tests__/PaymentBreakdownModal.test.tsx new file mode 100644 index 0000000..3457bb9 --- /dev/null +++ b/src/__tests__/PaymentBreakdownModal.test.tsx @@ -0,0 +1,207 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import PaymentBreakdownModal from "@/components/PaymentBreakdownModal"; + +vi.mock("@stellar-split/sdk", () => ({ + formatAmount: (n: bigint) => (Number(n) / 10_000_000).toFixed(2), +})); + +describe("PaymentBreakdownModal", () => { + const mockFeeBreakdown = { + gross: 100_000_000n, + fee: 1_000_000n, + net: 99_000_000n, + }; + + const mockOnConfirm = vi.fn(); + const mockOnBack = vi.fn(); + + beforeEach(() => { + mockOnConfirm.mockClear(); + mockOnBack.mockClear(); + }); + + test("renders modal with dialog role and aria-modal", () => { + render( + + ); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + test("displays breakdown title", () => { + render( + + ); + expect(screen.getByText("Payment Breakdown")).toBeInTheDocument(); + }); + + test("displays gross amount, protocol fee, net to recipients, and stellar fee", () => { + render( + + ); + expect(screen.getByText("Gross Amount")).toBeInTheDocument(); + expect(screen.getByText("Protocol Fee")).toBeInTheDocument(); + expect(screen.getByText("Net to Recipients")).toBeInTheDocument(); + expect(screen.getByText("Stellar Tx Fee")).toBeInTheDocument(); + }); + + test("displays formatted amounts correctly", () => { + render( + + ); + expect(screen.getByText(/10\.00 USDC/)).toBeInTheDocument(); + expect(screen.getByText(/-0\.10 USDC/)).toBeInTheDocument(); + }); + + test("calls onConfirm when Confirm & Pay button clicked", async () => { + const user = userEvent.setup(); + render( + + ); + const confirmButton = screen.getByRole("button", { name: /Confirm & Pay/ }); + await user.click(confirmButton); + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + + test("calls onBack when Back button clicked", async () => { + const user = userEvent.setup(); + render( + + ); + const backButton = screen.getByRole("button", { name: "Back" }); + await user.click(backButton); + expect(mockOnBack).toHaveBeenCalledTimes(1); + }); + + test("disables buttons when confirming is true", () => { + render( + + ); + const backButton = screen.getByRole("button", { name: "Back" }); + const confirmButton = screen.getByRole("button", { name: /Waiting for signature/ }); + expect(backButton).toBeDisabled(); + expect(confirmButton).toBeDisabled(); + }); + + test("shows waiting message when confirming", () => { + render( + + ); + expect(screen.getByText("Waiting for signature…")).toBeInTheDocument(); + }); + + test("displays fee explanation text", () => { + render( + + ); + expect(screen.getByText(/protocol fee is deducted/i)).toBeInTheDocument(); + }); + + test("calls onBack when clicking outside dialog", async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const dialog = screen.getByRole("dialog"); + await user.click(dialog); + expect(mockOnBack).not.toHaveBeenCalled(); + }); + + test("dismisses when clicking on backdrop overlay", async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const overlay = container.querySelector("[role='dialog']") as HTMLElement; + const backdrop = overlay.parentElement as HTMLElement; + + if (backdrop && backdrop !== overlay) { + await user.click(backdrop); + expect(mockOnBack).toHaveBeenCalled(); + } + }); + + test("renders with correct table structure", () => { + const { container } = render( + + ); + const table = container.querySelector("table"); + expect(table).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/PaymentProgress.test.tsx b/src/__tests__/PaymentProgress.test.tsx new file mode 100644 index 0000000..cf12153 --- /dev/null +++ b/src/__tests__/PaymentProgress.test.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import PaymentProgress from "@/components/PaymentProgress"; + +vi.mock("@stellar-split/sdk", () => ({ + formatAmount: (n: bigint) => (Number(n) / 10_000_000).toFixed(2), +})); + +describe("PaymentProgress", () => { + test("renders progress bar with correct aria-valuenow when partially funded", () => { + render( + + ); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "50"); + }); + + test("caps progress bar width at 100% when overpaid", () => { + render( + + ); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "100"); + + const fillDiv = progressBar.querySelector("div"); + expect(fillDiv).toHaveStyle({ width: "100%" }); + }); + + test("shows 0% when no funds received", () => { + render( + + ); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "0"); + }); + + test("displays funded and total amounts in USDC", () => { + render( + + ); + expect(screen.getByText(/5\.00 \/ 10\.00 USDC funded/)).toBeInTheDocument(); + }); + + test("uses invoice prop when provided", () => { + const mockInvoice = { + id: "inv-1", + status: "Pending" as const, + creator: "CREATOR", + recipients: [{ address: "RECIP", amount: 100_000_000n }], + token: "USDC", + deadline: 0, + funded: 75_000_000n, + payments: [], + }; + + render(); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "75"); + expect(screen.getByText(/7\.50 \/ 10\.00 USDC funded/)).toBeInTheDocument(); + }); + + test("aria-label describes funding percentage", () => { + render( + + ); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-label", "50% funded"); + }); + + test("does not display amount text without invoice prop", () => { + render( + + ); + expect(screen.queryByText(/USDC funded/)).not.toBeInTheDocument(); + }); + + test("handles edge case: total is zero", () => { + render( + + ); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "0"); + }); + + test("correctly displays overpayment scenario", () => { + const mockInvoice = { + id: "inv-1", + status: "Pending" as const, + creator: "CREATOR", + recipients: [{ address: "RECIP", amount: 100_000_000n }], + token: "USDC", + deadline: 0, + funded: 150_000_000n, + payments: [], + }; + + render(); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "100"); + expect(progressBar).toHaveAttribute("aria-label", "100% funded"); + }); +}); diff --git a/src/__tests__/StatusTimeline.test.tsx b/src/__tests__/StatusTimeline.test.tsx new file mode 100644 index 0000000..35006ad --- /dev/null +++ b/src/__tests__/StatusTimeline.test.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import StatusTimeline from "@/components/StatusTimeline"; +import type { Invoice } from "@stellar-split/sdk"; + +vi.mock("@/components/ui/RelativeTime", () => ({ + default: ({ iso }: { iso: string }) => {iso}, +})); + +describe("StatusTimeline", () => { + const createMockInvoice = (overrides?: Partial): Invoice => ({ + id: "inv-1", + status: "Pending" as const, + creator: "GCREATOR123456789", + recipients: [{ address: "GRECIPIENT1234567", amount: 100_000_000n }], + token: "USDC", + deadline: Math.floor(Date.now() / 1000) + 86400, + funded: 0n, + payments: [], + ...overrides, + }); + + test("renders status timeline section on desktop", () => { + const invoice = createMockInvoice(); + const { container } = render( + + ); + + const desktopSection = container.querySelector(".hidden.sm\\:block"); + expect(desktopSection).toBeInTheDocument(); + expect(screen.getByText("Timeline")).toBeInTheDocument(); + }); + + test("renders mobile stepper on mobile screens", () => { + const invoice = createMockInvoice(); + const { container } = render( + + ); + + const mobileSection = container.querySelector(".sm\\:hidden"); + expect(mobileSection).toBeInTheDocument(); + }); + + test("shows Created event with creator info", () => { + const invoice = createMockInvoice(); + render(); + + expect(screen.getByText("Invoice Created")).toBeInTheDocument(); + expect(screen.getByText(/GCREA/)).toBeInTheDocument(); + }); + + test("displays First Payment event when invoice has funded amount", () => { + const invoice = createMockInvoice({ funded: 50_000_000n }); + render(); + + expect(screen.getByText("First Payment")).toBeInTheDocument(); + }); + + test("does not show First Payment event when no payments", () => { + const invoice = createMockInvoice({ funded: 0n }); + render(); + + expect(screen.queryByText("First Payment")).not.toBeInTheDocument(); + }); + + test("displays Milestone event at 50% funding", () => { + const invoice = createMockInvoice({ funded: 50_000_000n }); + render(); + + expect(screen.getByText(/Milestone Reached \(50%\)/)).toBeInTheDocument(); + }); + + test("shows Fully Funded event when total reached", () => { + const invoice = createMockInvoice({ funded: 100_000_000n }); + render(); + + expect(screen.getByText("Fully Funded")).toBeInTheDocument(); + }); + + test("displays Funds Released event when status is Released", () => { + const invoice = createMockInvoice({ status: "Released" }); + render(); + + expect(screen.getByText("Funds Released")).toBeInTheDocument(); + }); + + test("shows expired event when deadline passed", () => { + const pastDeadline = Math.floor(Date.now() / 1000) - 3600; + const invoice = createMockInvoice({ deadline: pastDeadline }); + render(); + + expect(screen.getByText("Invoice Expired")).toBeInTheDocument(); + }); + + test("timeline events have icons as visual indicators", () => { + const invoice = createMockInvoice({ funded: 100_000_000n }); + const { container } = render( + + ); + + const timelineList = container.querySelector("[aria-label*='timeline']"); + expect(timelineList).toBeInTheDocument(); + + const iconSpans = container.querySelectorAll("span[aria-hidden='true']"); + expect(iconSpans.length).toBeGreaterThan(0); + }); + + test("mobile stepper shows correct active step when no funding", () => { + const invoice = createMockInvoice({ funded: 0n }); + const { container } = render( + + ); + + const steps = container.querySelectorAll(".sm\\:hidden .flex-1"); + expect(steps[0]).toHaveClass("text-white", "font-semibold"); + }); + + test("mobile stepper shows partially funded state", () => { + const invoice = createMockInvoice({ funded: 50_000_000n }); + render(); + + expect(screen.getByText("Partially Funded")).toBeInTheDocument(); + }); + + test("mobile stepper shows released state", () => { + const invoice = createMockInvoice({ status: "Released" }); + render(); + + expect(screen.getByText("Released")).toBeInTheDocument(); + }); + + test("timeline displays no events message when empty", () => { + const invoice = createMockInvoice({ + funded: 0n, + status: "Pending", + deadline: Math.floor(Date.now() / 1000) + 86400, + }); + render(); + + expect(screen.getByText("No events yet.")).toBeInTheDocument(); + }); + + test("events are sorted chronologically with latest highlighted", () => { + const invoice = createMockInvoice({ + funded: 100_000_000n, + status: "Released", + }); + const { container } = render( + + ); + + const dots = container.querySelectorAll(".rounded-full.text-sm"); + const lastDot = dots[dots.length - 1]; + expect(lastDot).toHaveClass("bg-indigo-600", "border-indigo-500"); + }); + + test("event labels are text-sm and readable", () => { + const invoice = createMockInvoice({ funded: 100_000_000n }); + render(); + + const labels = screen.getAllByText(/Fully Funded|Invoice Created/); + expect(labels.length).toBeGreaterThan(0); + labels.forEach((label) => { + expect(label.closest(".text-sm")).toBeInTheDocument(); + }); + }); + + test("truncates long addresses in event descriptions", () => { + const invoice = createMockInvoice({ + creator: "GCREATOR1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }); + render(); + + const creatorText = screen.getByText(/GCREA.*…/); + expect(creatorText.textContent).toMatch(/^GCREA/); + expect(creatorText.textContent).not.toMatch( + /GCREATOR1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ/ + ); + }); + + test("handles zero total amount gracefully", () => { + const invoice = createMockInvoice(); + render(); + + expect(screen.getByText("Invoice Created")).toBeInTheDocument(); + }); +});