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