diff --git a/README.md b/README.md index 3ab7b96..2d0aa34 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell - **Next.js 15** (App Router) with **React 19** - **TailwindCSS** for styling +- Operator pages for quotes, registered pairs, and per-pair fee edits - Starter landing page; Stellar wallet integration can be added here ## Prerequisites diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 2fa959f..328ce18 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -8,7 +8,7 @@ const sections = [ { h: "GET /api/v1/pairs", p: "List every registered pair. ETag caching." }, { h: "PATCH /api/v1/pairs/:src/:dest/fee_bps", - p: "Set the per-pair routing fee in basis points (0..1000).", + p: "Set the per-pair routing fee in basis points (0..1000) with { fee_bps }.", }, { h: "GET /api/v1/quote", diff --git a/src/app/pairs/page.test.tsx b/src/app/pairs/page.test.tsx new file mode 100644 index 0000000..0384b2a --- /dev/null +++ b/src/app/pairs/page.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ToastProvider } from "@/components/ToastProvider"; +import PairsPage from "./page"; + +function renderPairsPage() { + return render( + + + + ); +} + +function jsonResponse(body: unknown, ok = true) { + return { + ok, + json: async () => body, + } as unknown as Response; +} + +describe("PairsPage", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("renders pair fees and defaults missing fees to zero", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce( + jsonResponse({ + pairs: [ + { source: "USDC", destination: "EURC", fee_bps: 5 }, + { source: "XLM", destination: "USDC" }, + ], + }) + ); + + renderPairsPage(); + + expect(await screen.findByText("Current fee: 5 bps")).toBeInTheDocument(); + expect(screen.getByText("Current fee: 0 bps")).toBeInTheDocument(); + expect(screen.getByLabelText("Fee bps for USDC to EURC")).toHaveValue(5); + expect(screen.getByLabelText("Fee bps for XLM to USDC")).toHaveValue(0); + }); + + it("saves a valid fee with encoded path segments", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + pairs: [{ source: "USD/C", destination: "EUR C", fee_bps: 5 }], + }) + ) + .mockResolvedValueOnce( + jsonResponse({ source: "USD/C", destination: "EUR C", fee_bps: 25 }) + ); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + renderPairsPage(); + + fireEvent.change(await screen.findByLabelText("Fee bps for USD/C to EUR C"), { + target: { value: "25" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save fee for USD/C to EUR C" })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:3001/api/v1/pairs/USD%2FC/EUR%20C/fee_bps", + expect.objectContaining({ + body: JSON.stringify({ fee_bps: 25 }), + method: "PATCH", + }) + ); + }); + expect(await screen.findByText("Current fee: 25 bps")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Saved USD/C → EUR C fee"); + }); + + it.each(["1001", "-1", "1.5"])( + "rejects invalid fee %s before calling the backend", + async (invalidFee) => { + const mockFetch = jest.fn().mockResolvedValueOnce( + jsonResponse({ + pairs: [{ source: "USDC", destination: "EURC", fee_bps: 5 }], + }) + ); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + renderPairsPage(); + + fireEvent.change(await screen.findByLabelText("Fee bps for USDC to EURC"), { + target: { value: invalidFee }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save fee for USDC to EURC" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Fee must be an integer between 0 and 1000 bps." + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + } + ); + + it("rolls back the current fee and draft when the backend rejects the update", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + pairs: [{ source: "USDC", destination: "EURC", fee_bps: 5 }], + }) + ) + .mockResolvedValueOnce( + jsonResponse( + { error: "invalid_request", message: "fee cannot be changed" }, + false + ) + ); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + renderPairsPage(); + + const feeInput = await screen.findByLabelText("Fee bps for USDC to EURC"); + fireEvent.change(feeInput, { target: { value: "25" } }); + fireEvent.click(screen.getByRole("button", { name: "Save fee for USDC to EURC" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("fee cannot be changed"); + expect(screen.getByText("Current fee: 5 bps")).toBeInTheDocument(); + expect(feeInput).toHaveValue(5); + }); +}); diff --git a/src/app/pairs/page.tsx b/src/app/pairs/page.tsx index 1835140..27d64ca 100644 --- a/src/app/pairs/page.tsx +++ b/src/app/pairs/page.tsx @@ -1,20 +1,99 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { apiGet } from "@/lib/apiClient"; +import { Button } from "@/components/Button"; +import { TextField } from "@/components/TextField"; +import { useToast } from "@/components/ToastProvider"; +import { apiGet, apiPatch } from "@/lib/apiClient"; -type Pair = { source: string; destination: string }; +type Pair = { source: string; destination: string; fee_bps?: number }; + +const MAX_FEE_BPS = 1000; +const pairKey = (pair: Pair) => `${pair.source}::${pair.destination}`; + +function parseFeeBps(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const fee = Number(value); + return fee <= MAX_FEE_BPS ? fee : null; +} export default function PairsPage() { + const toast = useToast(); const [pairs, setPairs] = useState(null); + const [draftFees, setDraftFees] = useState>({}); + const [rowErrors, setRowErrors] = useState>({}); + const [savingKey, setSavingKey] = useState(null); const [error, setError] = useState(null); + const loadPairs = useCallback( + () => + apiGet<{ pairs: Pair[] }>("/api/v1/pairs").then((b) => { + setPairs(b.pairs); + setDraftFees( + Object.fromEntries( + b.pairs.map((pair) => [pairKey(pair), String(pair.fee_bps ?? 0)]) + ) + ); + }), + [] + ); + useEffect(() => { - apiGet<{ pairs: Pair[] }>("/api/v1/pairs") - .then((b) => setPairs(b.pairs)) - .catch((e) => setError(e.message)); - }, []); + loadPairs().catch((e) => setError(e.message)); + }, [loadPairs]); + + const saveFee = async (pair: Pair) => { + const key = pairKey(pair); + const fee = parseFeeBps(draftFees[key] ?? ""); + if (fee === null) { + setRowErrors((current) => ({ + ...current, + [key]: `Fee must be an integer between 0 and ${MAX_FEE_BPS} bps.`, + })); + return; + } + + const previousFee = pair.fee_bps ?? 0; + setRowErrors((current) => ({ ...current, [key]: "" })); + setSavingKey(key); + setPairs( + (current) => + current?.map((item) => + pairKey(item) === key ? { ...item, fee_bps: fee } : item + ) ?? null + ); + + try { + const updated = await apiPatch( + `/api/v1/pairs/${encodeURIComponent(pair.source)}/${encodeURIComponent( + pair.destination + )}/fee_bps`, + { fee_bps: fee } + ); + const savedFee = updated.fee_bps ?? fee; + setPairs((current) => + current?.map((item) => + pairKey(item) === key ? { ...item, fee_bps: savedFee } : item + ) ?? null + ); + setDraftFees((current) => ({ ...current, [key]: String(savedFee) })); + toast.push(`Saved ${pair.source} → ${pair.destination} fee`); + } catch (err) { + setPairs((current) => + current?.map((item) => + pairKey(item) === key ? { ...item, fee_bps: previousFee } : item + ) ?? null + ); + setDraftFees((current) => ({ ...current, [key]: String(previousFee) })); + setRowErrors((current) => ({ + ...current, + [key]: (err as Error).message, + })); + } finally { + setSavingKey(null); + } + }; return (
0 && (
    {pairs.map((p) => ( -
  • - {p.source} → {p.destination} +
  • +
    +

    + {p.source} → {p.destination} +

    +

    + Current fee: {p.fee_bps ?? 0} bps +

    +
    +
    +
    + { + const key = pairKey(p); + setDraftFees((current) => ({ + ...current, + [key]: e.target.value, + })); + setRowErrors((current) => ({ ...current, [key]: "" })); + }} + error={rowErrors[pairKey(p)]} + className="flex-1" + /> + +
    +
  • ))}