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();
+ });
+});