Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell
- **Next.js 15** (App Router) with **React 19**
- **TailwindCSS** for styling
- Starter landing page; Stellar wallet integration can be added here
- Shared `apiClient` helpers centralize API base URL and error parsing

## Prerequisites

Expand Down
191 changes: 169 additions & 22 deletions src/app/quote/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ToastProvider } from "@/components/ToastProvider";
import QuotePage from "./page";

function renderQuotePage() {
return render(
<ToastProvider>
<QuotePage />
</ToastProvider>
);
}

function jsonResponse(body: unknown, ok = true) {
return {
ok,
json: async () => body,
} as unknown as Response;
}

describe("QuotePage", () => {
let originalFetch: typeof globalThis.fetch;

Expand All @@ -13,26 +29,26 @@ describe("QuotePage", () => {
});

it("renders the heading and form fields", () => {
render(<QuotePage />);
renderQuotePage();
expect(screen.getByRole("heading", { name: /Get a quote/i })).toBeInTheDocument();
expect(screen.getByLabelText(/Source asset/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Destination asset/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Amount/i)).toBeInTheDocument();
});

it("calls the backend and renders the route on success", async () => {
globalThis.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
it("calls apiClient, renders a multi-hop route, and emits a success toast", async () => {
const mockFetch = jest.fn().mockResolvedValueOnce(
jsonResponse({
source_asset: "USDC",
dest_asset: "EURC",
amount: "1000000",
estimated_rate: "1.0",
route: ["USDC", "EURC"],
}),
} as unknown as Response);
route: ["USDC", "XLM", "EURC"],
})
);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

render(<QuotePage />);
renderQuotePage();
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
});
Expand All @@ -45,14 +61,22 @@ describe("QuotePage", () => {
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

await waitFor(() => {
expect(screen.getByRole("status")).toHaveTextContent(/USDC → EURC/);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3001/api/v1/quote?source_asset=USDC&dest_asset=EURC&amount=1000000",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
})
);
});
expect(await screen.findByText("Route: USDC → XLM → EURC")).toBeInTheDocument();
expect(screen.getByText("Amount: 1000000")).toBeInTheDocument();
expect(screen.getByText("Quote ready for USDC → EURC")).toBeInTheDocument();
});

it("blocks submission when source == destination", async () => {
const mockFetch = jest.fn();
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
render(<QuotePage />);
renderQuotePage();

fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
Expand All @@ -75,7 +99,7 @@ describe("QuotePage", () => {
it("blocks submission when amount is not a positive integer", async () => {
const mockFetch = jest.fn();
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
render(<QuotePage />);
renderQuotePage();

fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
Expand All @@ -96,15 +120,17 @@ describe("QuotePage", () => {
});

it("surfaces a backend invalid_request as a role=alert", async () => {
globalThis.fetch = jest.fn().mockResolvedValueOnce({
ok: false,
json: async () => ({
error: "invalid_request",
message: "source_asset and dest_asset must differ",
}),
} as unknown as Response);

render(<QuotePage />);
globalThis.fetch = jest.fn().mockResolvedValueOnce(
jsonResponse(
{
error: "invalid_request",
message: "source_asset and dest_asset must differ",
},
false
)
);

renderQuotePage();
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
});
Expand All @@ -117,7 +143,128 @@ describe("QuotePage", () => {
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/must differ/i);
const alerts = screen.getAllByRole("alert");
expect(alerts[0]).toHaveTextContent(/must differ/i);
expect(alerts[1]).toHaveTextContent(/must differ/i);
});
});

it("surfaces a network rejection inline and as an error toast", async () => {
globalThis.fetch = jest.fn().mockRejectedValueOnce(new Error("network down"));

renderQuotePage();
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
});
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
target: { value: "EURC" },
});
fireEvent.change(screen.getByLabelText(/Amount/i), {
target: { value: "100" },
});
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

await waitFor(() => {
const alerts = screen.getAllByRole("alert");
expect(alerts[0]).toHaveTextContent("network down");
expect(alerts[1]).toHaveTextContent("network down");
});
});

it("keeps the submit button disabled while the quote is loading", async () => {
let resolveQuote!: (value: Response) => void;
const quotePromise = new Promise<Response>((resolve) => {
resolveQuote = resolve;
});
globalThis.fetch = jest.fn().mockReturnValueOnce(quotePromise);

renderQuotePage();
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
});
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
target: { value: "EURC" },
});
fireEvent.change(screen.getByLabelText(/Amount/i), {
target: { value: "100" },
});
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

expect(screen.getByRole("button", { name: /Quoting/i })).toBeDisabled();

resolveQuote(
jsonResponse({
source_asset: "USDC",
dest_asset: "EURC",
amount: "100",
estimated_rate: "1.0",
route: ["USDC", "EURC"],
})
);

await waitFor(() => {
expect(screen.getByRole("button", { name: /Get quote/i })).not.toBeDisabled();
});
});

it("encodes quote query parameters through URLSearchParams", async () => {
const mockFetch = jest.fn().mockResolvedValueOnce(
jsonResponse({
source_asset: "USD/C",
dest_asset: "EUR C",
amount: "100",
estimated_rate: "1.0",
route: ["USD/C", "EUR C"],
})
);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

renderQuotePage();
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USD/C" },
});
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
target: { value: "EUR C" },
});
fireEvent.change(screen.getByLabelText(/Amount/i), {
target: { value: "100" },
});
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3001/api/v1/quote?source_asset=USD%2FC&dest_asset=EUR+C&amount=100",
expect.any(Object)
);
});
});

it("uses the ApiError fallback message when the backend omits message", async () => {
globalThis.fetch = jest.fn().mockResolvedValueOnce(
jsonResponse(
{
error: "invalid_request",
},
false
)
);

renderQuotePage();
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
});
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
target: { value: "EURC" },
});
fireEvent.change(screen.getByLabelText(/Amount/i), {
target: { value: "100" },
});
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

await waitFor(() => {
const alerts = screen.getAllByRole("alert");
expect(alerts[0]).toHaveTextContent("quote request failed");
expect(alerts[1]).toHaveTextContent("quote request failed");
});
});
});
29 changes: 17 additions & 12 deletions src/app/quote/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useState } from "react";
import { useToast } from "@/components/ToastProvider";
import { apiGet, type ApiError } from "@/lib/apiClient";

type Quote = {
source_asset: string;
Expand All @@ -10,10 +12,8 @@ type Quote = {
route: string[];
};

const API_BASE =
process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE ?? "http://localhost:3001";

export default function QuotePage() {
const toast = useToast();
const [sourceAsset, setSourceAsset] = useState("");
const [destAsset, setDestAsset] = useState("");
const [amount, setAmount] = useState("");
Expand All @@ -37,16 +37,21 @@ export default function QuotePage() {

setLoading(true);
try {
const url = `${API_BASE}/api/v1/quote?source_asset=${encodeURIComponent(sourceAsset)}&dest_asset=${encodeURIComponent(destAsset)}&amount=${encodeURIComponent(amount)}`;
const res = await fetch(url);
const body = await res.json();
if (!res.ok) {
setError(body?.message ?? "quote request failed");
return;
}
setQuote(body as Quote);
const params = new URLSearchParams({
source_asset: sourceAsset,
dest_asset: destAsset,
amount,
});
const body = await apiGet<Quote>(`/api/v1/quote?${params.toString()}`);
setQuote(body);
toast.push(`Quote ready for ${body.source_asset} → ${body.dest_asset}`);
} catch (err) {
setError(err instanceof Error ? err.message : "network error");
const apiError = err as Partial<ApiError>;
const message =
apiError.message ||
(err instanceof Error && err.message ? err.message : "quote request failed");
setError(message);
toast.push(message, "error");
} finally {
setLoading(false);
}
Expand Down