Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/app/credits/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={client}><CreditsPage /></QueryClientProvider>); }

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(); });
});
41 changes: 41 additions & 0 deletions frontend/app/payments/[id]/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={client}><PaymentDetailPage /></QueryClientProvider>);
}
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();
});
});
17 changes: 17 additions & 0 deletions frontend/app/payments/metrics/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={client}><MetricsPage /></QueryClientProvider>);
}
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(); });
});
60 changes: 60 additions & 0 deletions frontend/app/payments/new/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NewPaymentPage />);
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(<NewPaymentPage />);
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(<NewPaymentPage />);
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(<NewPaymentPage />);
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();
});
});
42 changes: 42 additions & 0 deletions frontend/app/payments/return/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PaymentReturnPage />);
expect(screen.getByText(/Contacting your payment provider/)).toBeInTheDocument();
});

it("renders a verified payment", async () => {
mocks.verify.mockResolvedValue({ payment, verified: true });
render(<PaymentReturnPage />);
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(<PaymentReturnPage />);
await waitFor(() => expect(mocks.getPayment).toHaveBeenCalledWith("pay-123"));
expect(await screen.findByText("Payment verified")).toBeInTheDocument();
warn.mockRestore();
});
});
19 changes: 19 additions & 0 deletions frontend/app/usage/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={client}><UsagePage /></QueryClientProvider>); }

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(); });
});
14 changes: 14 additions & 0 deletions frontend/app/wallet/__tests__/error.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WalletError error={new Error("wallet failed")} reset={reset} />);
expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong loading your wallet");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reset).toHaveBeenCalledOnce();
});
});
63 changes: 63 additions & 0 deletions frontend/lib/__tests__/use-payment-status.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, (value?: unknown) => 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 <button onClick={() => client.setQueryData(["payments", "detail", "pay-123"], { id: "pay-123", status: "INITIATED" })}>Seed</button>;
}

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(<QueryClientProvider client={client}><Probe /></QueryClientProvider>);
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(<QueryClientProvider client={client}><Probe /></QueryClientProvider>);
act(() => { vi.advanceTimersByTime(60_000); });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["payments", "detail", "pay-123"] });
view.unmount();
client.clear();
});
});
Loading