diff --git a/frontend/app/credits/__tests__/page.test.tsx b/frontend/app/credits/__tests__/page.test.tsx
new file mode 100644
index 00000000..79e3893f
--- /dev/null
+++ b/frontend/app/credits/__tests__/page.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import CreditsPage from "../page";
+import { useSessionStore } from "@/lib/stores/session-store";
+
+const mocks = vi.hoisted(() => ({ balance: vi.fn(), statement: vi.fn() }));
+vi.mock("@/lib/credits-api", () => ({ getCreditBalance: mocks.balance, getCreditStatement: mocks.statement }));
+vi.mock("@/lib/stores/use-session-bootstrap", () => ({ useSessionBootstrap: () => {} }));
+vi.mock("@/components/auth/user-menu", () => ({ UserMenu: () => null }));
+function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render(); }
+
+describe("CreditsPage", () => {
+ beforeEach(() => { useSessionStore.setState({ user: { id: "u1", email: "a@example.com", role: "user" }, accessToken: "token", status: "authenticated" }, false); mocks.balance.mockReset(); mocks.statement.mockReset(); });
+ afterEach(() => useSessionStore.setState({ user: null, accessToken: null, status: "unknown" }, false));
+ it("shows loading indicators", () => { mocks.balance.mockReturnValue(new Promise(() => {})); mocks.statement.mockReturnValue(new Promise(() => {})); renderPage(); expect(screen.getByText("Loading…")).toBeInTheDocument(); });
+ it("shows statement errors", async () => { mocks.balance.mockResolvedValue({ balance: 0, currency: "USD", overdraftLimit: 0 }); mocks.statement.mockRejectedValue(new Error("Ledger unavailable")); renderPage(); expect(await screen.findByText("Ledger unavailable")).toBeInTheDocument(); });
+ it("shows the balance and empty statement state", async () => { mocks.balance.mockResolvedValue({ balance: 2500, currency: "USD", overdraftLimit: 0 }); mocks.statement.mockResolvedValue({ entries: [], total: 0, page: 1, pageSize: 20 }); renderPage(); expect(await screen.findByText("Available balance")).toBeInTheDocument(); expect(await screen.findByText("No transactions yet.")).toBeInTheDocument(); });
+});
diff --git a/frontend/app/payments/[id]/__tests__/page.test.tsx b/frontend/app/payments/[id]/__tests__/page.test.tsx
new file mode 100644
index 00000000..804a67bb
--- /dev/null
+++ b/frontend/app/payments/[id]/__tests__/page.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import PaymentDetailPage from "../page";
+import { useSessionStore } from "@/lib/stores/session-store";
+
+const mocks = vi.hoisted(() => ({ getPayment: vi.fn(), usePaymentStatus: vi.fn() }));
+vi.mock("next/navigation", () => ({ useParams: () => ({ id: "pay-123" }) }));
+vi.mock("@/lib/payments-api", () => ({ getPayment: mocks.getPayment }));
+vi.mock("@/lib/use-payment-status", () => ({ usePaymentStatus: mocks.usePaymentStatus }));
+
+function renderPage() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render();
+}
+const payment = { id: "pay-123", bookingId: "booking-1", amount: 1250, currency: "USD", rail: "FIAT", status: "CONFIRMED", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", provider: null, providerReference: null, failureReason: null, manualReviewReason: null };
+
+describe("PaymentDetailPage", () => {
+ beforeEach(() => {
+ useSessionStore.setState({ user: { id: "u1", email: "a@example.com", role: "user" }, accessToken: "token", status: "authenticated" }, false);
+ mocks.getPayment.mockReset();
+ });
+ afterEach(() => useSessionStore.setState({ user: null, accessToken: null, status: "unknown" }, false));
+
+ it("shows loading while the payment is fetched", () => {
+ mocks.getPayment.mockReturnValue(new Promise(() => {}));
+ renderPage();
+ expect(screen.getByText("Loading payment...")).toBeInTheDocument();
+ });
+ it("shows an API error", async () => {
+ mocks.getPayment.mockRejectedValue(new Error("Payment not found"));
+ renderPage();
+ expect(await screen.findByText("Payment not found")).toBeInTheDocument();
+ });
+ it("renders payment details on success", async () => {
+ mocks.getPayment.mockResolvedValue(payment);
+ renderPage();
+ expect(await screen.findByText("CONFIRMED")).toBeInTheDocument();
+ expect(screen.getByText("booking-1")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/app/payments/metrics/__tests__/page.test.tsx b/frontend/app/payments/metrics/__tests__/page.test.tsx
new file mode 100644
index 00000000..b28a16f4
--- /dev/null
+++ b/frontend/app/payments/metrics/__tests__/page.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { describe, expect, it, vi } from "vitest";
+import MetricsPage from "../page";
+
+const mocks = vi.hoisted(() => ({ fetchPaymentMetrics: vi.fn() }));
+vi.mock("@/lib/payment-metrics", () => ({ fetchPaymentMetrics: mocks.fetchPaymentMetrics, PAYMENT_METRICS_REFETCH_INTERVAL: 30_000 }));
+function renderPage() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render();
+}
+const metrics = { totalVolume: 1234, successfulTransactions: 8, failedTransactions: 2, dailyRevenue: [], statusDistribution: [] };
+describe("Payment metrics page", () => {
+ it("shows a loading indicator", () => { mocks.fetchPaymentMetrics.mockReturnValue(new Promise(() => {})); renderPage(); expect(document.querySelector(".animate-spin")).toBeInTheDocument(); });
+ it("shows a request error", async () => { mocks.fetchPaymentMetrics.mockRejectedValue(new Error("Metrics unavailable")); renderPage(); expect(await screen.findByRole("alert")).toHaveTextContent("Metrics unavailable"); });
+ it("renders summary metrics", async () => { mocks.fetchPaymentMetrics.mockResolvedValue(metrics); renderPage(); expect(await screen.findByText("Payment Metrics Dashboard")).toBeInTheDocument(); expect(screen.getByText("$1,234")).toBeInTheDocument(); });
+});
diff --git a/frontend/app/payments/new/__tests__/page.test.tsx b/frontend/app/payments/new/__tests__/page.test.tsx
new file mode 100644
index 00000000..531286b5
--- /dev/null
+++ b/frontend/app/payments/new/__tests__/page.test.tsx
@@ -0,0 +1,60 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import NewPaymentPage from "../page";
+
+const mocks = vi.hoisted(() => ({
+ initiatePayment: vi.fn(),
+ success: vi.fn(),
+ error: vi.fn(),
+ randomUUID: vi.fn(() => "idem-key-123"),
+}));
+
+vi.mock("@/lib/payments-api", () => ({ initiatePayment: mocks.initiatePayment }));
+vi.mock("sonner", () => ({ toast: { success: mocks.success, error: mocks.error } }));
+
+describe("NewPaymentPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal("crypto", { ...crypto, randomUUID: mocks.randomUUID });
+ });
+
+ it("renders the payment form", () => {
+ render();
+ expect(screen.getByRole("heading", { name: "Initiate payment" })).toBeInTheDocument();
+ expect(screen.getByLabelText("Booking ID")).toBeInTheDocument();
+ });
+
+ it("shows the created payment on success and passes an idempotency key", async () => {
+ mocks.initiatePayment.mockResolvedValue({ id: "pay-123" });
+ render();
+ fireEvent.change(screen.getByLabelText("Booking ID"), { target: { value: "booking-1" } });
+ fireEvent.change(screen.getByLabelText("Amount (minor units)"), { target: { value: "5000" } });
+ fireEvent.click(screen.getByRole("button", { name: "Initiate payment" }));
+
+ await waitFor(() => expect(screen.getByText("pay-123")).toBeInTheDocument());
+ expect(mocks.initiatePayment).toHaveBeenCalledWith(
+ { bookingId: "booking-1", amount: 5000, currency: "USD", rail: "FIAT", provider: undefined },
+ "idem-key-123",
+ );
+ expect(mocks.success).toHaveBeenCalledWith("Payment initiated");
+ });
+
+ it("disables the submit button while the request is pending", async () => {
+ mocks.initiatePayment.mockReturnValue(new Promise(() => {}));
+ render();
+ fireEvent.change(screen.getByLabelText("Booking ID"), { target: { value: "booking-1" } });
+ fireEvent.change(screen.getByLabelText("Amount (minor units)"), { target: { value: "5000" } });
+ fireEvent.click(screen.getByRole("button", { name: "Initiate payment" }));
+ await waitFor(() => expect(screen.getByRole("button")).toBeDisabled());
+ });
+
+ it("reports request errors", async () => {
+ mocks.initiatePayment.mockRejectedValue(new Error("Payment service unavailable"));
+ render();
+ fireEvent.change(screen.getByLabelText("Booking ID"), { target: { value: "booking-1" } });
+ fireEvent.change(screen.getByLabelText("Amount (minor units)"), { target: { value: "5000" } });
+ fireEvent.click(screen.getByRole("button", { name: "Initiate payment" }));
+ await waitFor(() => expect(mocks.error).toHaveBeenCalledWith("Payment service unavailable"));
+ expect(screen.queryByText(/Payment created:/)).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/app/payments/return/__tests__/page.test.tsx b/frontend/app/payments/return/__tests__/page.test.tsx
new file mode 100644
index 00000000..d7d74cfb
--- /dev/null
+++ b/frontend/app/payments/return/__tests__/page.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import PaymentReturnPage from "../page";
+import { useSessionStore } from "@/lib/stores/session-store";
+
+const mocks = vi.hoisted(() => ({ searchParams: new URLSearchParams("paymentId=pay-123"), push: vi.fn(), verify: vi.fn(), getPayment: vi.fn() }));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mocks.push }), useSearchParams: () => mocks.searchParams }));
+vi.mock("@/lib/stores/use-session-bootstrap", () => ({ useSessionBootstrap: () => {} }));
+vi.mock("@/lib/payments-api", () => ({ verifyPaymentReturn: mocks.verify, getPayment: mocks.getPayment }));
+const payment = { id: "pay-123", amount: 1250, currency: "USD", status: "CONFIRMED", provider: null, providerReference: null };
+
+describe("PaymentReturnPage", () => {
+ beforeEach(() => {
+ mocks.searchParams = new URLSearchParams("paymentId=pay-123");
+ mocks.verify.mockReset();
+ mocks.getPayment.mockReset();
+ useSessionStore.setState({ user: { id: "u1", email: "a@example.com", role: "user" }, accessToken: "token", status: "authenticated" }, false);
+ });
+
+ it("shows its fast-path loading state", () => {
+ mocks.verify.mockReturnValue(new Promise(() => {}));
+ render();
+ expect(screen.getByText(/Contacting your payment provider/)).toBeInTheDocument();
+ });
+
+ it("renders a verified payment", async () => {
+ mocks.verify.mockResolvedValue({ payment, verified: true });
+ render();
+ expect(await screen.findByText("Payment verified")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "View payment details" })).toHaveAttribute("href", "/payments/pay-123");
+ });
+
+ it("falls back to polling after verification fails and shows the terminal result", async () => {
+ mocks.verify.mockRejectedValue(new Error("Webhook not ready"));
+ mocks.getPayment.mockResolvedValue(payment);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ render();
+ await waitFor(() => expect(mocks.getPayment).toHaveBeenCalledWith("pay-123"));
+ expect(await screen.findByText("Payment verified")).toBeInTheDocument();
+ warn.mockRestore();
+ });
+});
diff --git a/frontend/app/usage/__tests__/page.test.tsx b/frontend/app/usage/__tests__/page.test.tsx
new file mode 100644
index 00000000..77ec1d48
--- /dev/null
+++ b/frontend/app/usage/__tests__/page.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import UsagePage from "../page";
+import { useSessionStore } from "@/lib/stores/session-store";
+
+const mocks = vi.hoisted(() => ({ listMyUsage: vi.fn() }));
+vi.mock("@/lib/payments-api", () => ({ listMyUsage: mocks.listMyUsage }));
+vi.mock("@/lib/stores/use-session-bootstrap", () => ({ useSessionBootstrap: () => {} }));
+vi.mock("@/components/auth/user-menu", () => ({ UserMenu: () => null }));
+function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render(); }
+
+describe("UsagePage", () => {
+ beforeEach(() => { useSessionStore.setState({ user: { id: "u1", email: "a@example.com", role: "user" }, accessToken: "token", status: "authenticated" }, false); mocks.listMyUsage.mockReset(); });
+ afterEach(() => useSessionStore.setState({ user: null, accessToken: null, status: "unknown" }, false));
+ it("shows loading", () => { mocks.listMyUsage.mockReturnValue(new Promise(() => {})); renderPage(); expect(screen.getByText("Loading…")).toBeInTheDocument(); });
+ it("shows API errors", async () => { mocks.listMyUsage.mockRejectedValue(new Error("Usage unavailable")); renderPage(); expect(await screen.findByText("Usage unavailable")).toBeInTheDocument(); });
+ it("shows the empty state for a successful empty response", async () => { mocks.listMyUsage.mockResolvedValue([]); renderPage(); expect(await screen.findByText("No usage recorded.")).toBeInTheDocument(); });
+});
diff --git a/frontend/app/wallet/__tests__/error.test.tsx b/frontend/app/wallet/__tests__/error.test.tsx
new file mode 100644
index 00000000..3f713da4
--- /dev/null
+++ b/frontend/app/wallet/__tests__/error.test.tsx
@@ -0,0 +1,14 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import WalletError from "../error";
+
+vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn() }));
+describe("WalletError", () => {
+ it("shows the boundary error and lets the user retry", () => {
+ const reset = vi.fn();
+ render();
+ expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong loading your wallet");
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(reset).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/lib/__tests__/use-payment-status.test.tsx b/frontend/lib/__tests__/use-payment-status.test.tsx
new file mode 100644
index 00000000..e1186bc5
--- /dev/null
+++ b/frontend/lib/__tests__/use-payment-status.test.tsx
@@ -0,0 +1,63 @@
+import { act, render } from "@testing-library/react";
+import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { usePaymentStatus } from "../use-payment-status";
+
+const socketMocks = vi.hoisted(() => ({
+ handlers: {} as Record void>,
+ emit: vi.fn(),
+ disconnect: vi.fn(),
+ connected: false,
+}));
+vi.mock("socket.io-client", () => ({
+ io: vi.fn(() => ({
+ get connected() { return socketMocks.connected; },
+ on: (event: string, handler: (value?: unknown) => void) => { socketMocks.handlers[event] = handler; return this; },
+ emit: socketMocks.emit,
+ disconnect: socketMocks.disconnect,
+ })),
+}));
+
+function Probe() {
+ const client = useQueryClient();
+ usePaymentStatus("pay-123", "secret-token");
+ return ;
+}
+
+describe("usePaymentStatus", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ socketMocks.handlers = {};
+ socketMocks.connected = false;
+ socketMocks.emit.mockClear();
+ socketMocks.disconnect.mockClear();
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("subscribes to payment updates and refreshes cached status for matching events", async () => {
+ const client = new QueryClient();
+ const invalidate = vi.spyOn(client, "invalidateQueries").mockResolvedValue();
+ const view = render();
+ act(() => { socketMocks.handlers.connect(); });
+ expect(socketMocks.emit).toHaveBeenCalledWith("subscribe", "pay-123");
+ view.getByRole("button", { name: "Seed" }).click();
+ act(() => { socketMocks.handlers["payment:update"]({ paymentId: "other", status: "CONFIRMED" }); });
+ expect(client.getQueryData(["payments", "detail", "pay-123"])).toMatchObject({ status: "INITIATED" });
+ act(() => { socketMocks.handlers["payment:update"]({ paymentId: "pay-123", status: "CONFIRMED" }); });
+ expect(client.getQueryData(["payments", "detail", "pay-123"])).toMatchObject({ status: "CONFIRMED" });
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ["payments", "detail", "pay-123"] });
+ view.unmount();
+ expect(socketMocks.disconnect).toHaveBeenCalledOnce();
+ client.clear();
+ });
+
+ it("invalidates the payment query on the polling fallback while disconnected", () => {
+ const client = new QueryClient();
+ const invalidate = vi.spyOn(client, "invalidateQueries").mockResolvedValue();
+ const view = render();
+ act(() => { vi.advanceTimersByTime(60_000); });
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ["payments", "detail", "pay-123"] });
+ view.unmount();
+ client.clear();
+ });
+});