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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell
| `npm test` | Run Jest tests |
| `npm run lint` | Next.js ESLint |

## Data fetching

Read-only dashboard pages should use `src/lib/useApi.ts` instead of duplicating `useEffect`, `useState`, and `apiGet` in each page. The hook exposes a `loading | error | ok` state union so pages can consistently map pending requests to `Spinner`, failures to `role="alert"`, and successful responses to list rendering.

## CI/CD

On every push/PR to `main`, GitHub Actions runs:
Expand Down
63 changes: 63 additions & 0 deletions src/app/events/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import EventsPage from "./page";

function jsonResponse(body: unknown, init: Partial<Response> = {}) {
return {
ok: init.ok ?? true,
status: init.status ?? 200,
json: async () => body,
} as unknown as Response;
}

afterEach(() => jest.restoreAllMocks());

it("shows the shared loading spinner", () => {
globalThis.fetch = jest.fn().mockReturnValue(new Promise(() => undefined));

render(<EventsPage />);

expect(screen.getByRole("status")).toHaveTextContent("Loading events");
});

it("renders events returned by useApi", async () => {
globalThis.fetch = jest.fn().mockResolvedValue(
jsonResponse({
items: [
{
id: "evt_1",
ts: 1700000000000,
type: "route.created",
payload: { source: "USDC" },
},
],
})
);

render(<EventsPage />);

expect(await screen.findByText("route.created")).toBeInTheDocument();
expect(screen.getByText(/USDC/)).toBeInTheDocument();
});

it("renders the empty state", async () => {
globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse({ items: [] }));

render(<EventsPage />);

expect(await screen.findByText("No events.")).toBeInTheDocument();
});

it("renders fetch errors as an alert", async () => {
globalThis.fetch = jest.fn().mockResolvedValue(
jsonResponse(
{ error: "backend_error", message: "events unavailable" },
{ ok: false, status: 503 }
)
);

render(<EventsPage />);

expect(await screen.findByRole("alert")).toHaveTextContent(
"events unavailable"
);
});
41 changes: 24 additions & 17 deletions src/app/events/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import { apiGet } from "@/lib/apiClient";
import { Spinner } from "@/components/Spinner";
import { useApi } from "@/lib/useApi";

type AppEvent = {
id: string;
Expand All @@ -10,15 +10,15 @@ type AppEvent = {
payload: Record<string, unknown>;
};

export default function EventsPage() {
const [items, setItems] = useState<AppEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
type EventsResponse = { items: AppEvent[] };

useEffect(() => {
apiGet<{ items: AppEvent[] }>("/api/v1/events?limit=100")
.then((b) => setItems(b.items))
.catch((e) => setError(e.message));
}, []);
/**
* Uses the shared read-only API hook so loading, error, and ok states match
* other dashboard fetch surfaces.
*/
export default function EventsPage() {
const state = useApi<EventsResponse>("/api/v1/events?limit=100");
const items = state.status === "ok" ? state.data.items : null;

return (
<main
Expand All @@ -27,23 +27,30 @@ export default function EventsPage() {
className="mx-auto flex min-h-[60vh] max-w-4xl flex-col gap-6 p-8 focus:outline-none"
>
<h1 className="text-3xl font-semibold tracking-tight">Event log</h1>
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
{state.status === "loading" && <Spinner label="Loading events" />}
{state.status === "error" && (
<p role="alert" className="text-sm text-rose-600">
{state.error}
</p>
)}
{items && items.length === 0 && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">No events.</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
No events.
</p>
)}
{items && items.length > 0 && (
<ol className="flex flex-col gap-2">
{items.map((e) => (
{items.map((event) => (
<li
key={e.id}
key={event.id}
className="rounded border border-neutral-200 p-3 font-mono text-xs dark:border-neutral-800"
>
<div className="flex justify-between text-neutral-500">
<span>{e.type}</span>
<span>{new Date(e.ts).toISOString()}</span>
<span>{event.type}</span>
<span>{new Date(event.ts).toISOString()}</span>
</div>
<pre className="mt-2 whitespace-pre-wrap break-words">
{JSON.stringify(e.payload, null, 2)}
{JSON.stringify(event.payload, null, 2)}
</pre>
</li>
))}
Expand Down
55 changes: 55 additions & 0 deletions src/app/pairs/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import PairsPage from "./page";

function jsonResponse(body: unknown, init: Partial<Response> = {}) {
return {
ok: init.ok ?? true,
status: init.status ?? 200,
json: async () => body,
} as unknown as Response;
}

afterEach(() => jest.restoreAllMocks());

it("shows the shared loading spinner", () => {
globalThis.fetch = jest.fn().mockReturnValue(new Promise(() => undefined));

render(<PairsPage />);

expect(screen.getByRole("status")).toHaveTextContent("Loading pairs");
});

it("renders pairs returned by useApi", async () => {
globalThis.fetch = jest.fn().mockResolvedValue(
jsonResponse({
pairs: [{ source: "USDC", destination: "EURC" }],
})
);

render(<PairsPage />);

expect(await screen.findByText("USDC -> EURC")).toBeInTheDocument();
});

it("renders the empty state", async () => {
globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse({ pairs: [] }));

render(<PairsPage />);

expect(await screen.findByText("No pairs registered yet.")).toBeInTheDocument();
});

it("renders fetch errors as an alert", async () => {
globalThis.fetch = jest.fn().mockResolvedValue(
jsonResponse(
{ error: "backend_error", message: "pairs unavailable" },
{ ok: false, status: 503 }
)
);

render(<PairsPage />);

expect(await screen.findByRole("alert")).toHaveTextContent(
"pairs unavailable"
);
});
31 changes: 16 additions & 15 deletions src/app/pairs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import { apiGet } from "@/lib/apiClient";
import { Spinner } from "@/components/Spinner";
import { useApi } from "@/lib/useApi";

type Pair = { source: string; destination: string };
type PairsResponse = { pairs: Pair[] };

/**
* Keeps the dashboard list aligned with the shared read-only API state model.
*/
export default function PairsPage() {
const [pairs, setPairs] = useState<Pair[] | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
apiGet<{ pairs: Pair[] }>("/api/v1/pairs")
.then((b) => setPairs(b.pairs))
.catch((e) => setError(e.message));
}, []);
const state = useApi<PairsResponse>("/api/v1/pairs");
const pairs = state.status === "ok" ? state.data.pairs : null;

return (
<main
Expand All @@ -31,12 +29,12 @@ export default function PairsPage() {
New pair
</Link>
</header>
{error && (
{state.status === "loading" && <Spinner label="Loading pairs" />}
{state.status === "error" && (
<p role="alert" className="text-sm text-rose-600">
{error}
{state.error}
</p>
)}
{!pairs && !error && <p>Loading…</p>}
{pairs && pairs.length === 0 && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
No pairs registered yet.
Expand All @@ -45,8 +43,11 @@ export default function PairsPage() {
{pairs && pairs.length > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{pairs.map((p) => (
<li key={`${p.source}::${p.destination}`} className="py-3 font-mono text-sm">
{p.source} → {p.destination}
<li
key={`${p.source}::${p.destination}`}
className="py-3 font-mono text-sm"
>
{p.source} -&gt; {p.destination}
</li>
))}
</ul>
Expand Down
78 changes: 78 additions & 0 deletions src/lib/__tests__/useApi.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { useApi } from "../useApi";

function Probe({ path }: { path: string | null }) {
const state = useApi<{ value: string }>(path);

if (state.status === "loading") return <p role="status">Loading</p>;
if (state.status === "error") return <p role="alert">{state.error}</p>;
return <p>{state.data.value}</p>;
}

function jsonResponse(body: unknown, init: Partial<Response> = {}) {
return {
ok: init.ok ?? true,
status: init.status ?? 200,
json: async () => body,
} as unknown as Response;
}

afterEach(() => jest.restoreAllMocks());

it("loads data and exposes ok state", async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValue(jsonResponse({ value: "ready" }));

render(<Probe path="/api/v1/demo" />);

expect(screen.getByRole("status")).toHaveTextContent("Loading");
expect(await screen.findByText("ready")).toBeInTheDocument();
});

it("surfaces API failures as error state", async () => {
globalThis.fetch = jest.fn().mockResolvedValue(
jsonResponse(
{ error: "bad_request", message: "request failed" },
{ ok: false, status: 400 }
)
);

render(<Probe path="/api/v1/demo" />);

expect(await screen.findByRole("alert")).toHaveTextContent(
"request failed"
);
});

it("does not fetch when path is null", () => {
globalThis.fetch = jest.fn();

render(<Probe path={null} />);

expect(screen.getByRole("status")).toHaveTextContent("Loading");
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it("does not update state after unmounting an in-flight request", async () => {
let resolveFetch!: (value: Response) => void;
const fetchPromise = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => undefined);
globalThis.fetch = jest.fn().mockReturnValue(fetchPromise);

const { unmount } = render(<Probe path="/api/v1/demo" />);
unmount();

await act(async () => {
resolveFetch(jsonResponse({ value: "late" }));
await fetchPromise;
});

await waitFor(() => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});