diff --git a/README.md b/README.md index 3ab7b96..2609ca1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/app/events/page.test.tsx b/src/app/events/page.test.tsx new file mode 100644 index 0000000..2798c88 --- /dev/null +++ b/src/app/events/page.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import EventsPage from "./page"; + +function jsonResponse(body: unknown, init: Partial = {}) { + 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(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "events unavailable" + ); +}); diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index fcf5ef0..4aa36c7 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -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; @@ -10,15 +10,15 @@ type AppEvent = { payload: Record; }; -export default function EventsPage() { - const [items, setItems] = useState(null); - const [error, setError] = useState(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("/api/v1/events?limit=100"); + const items = state.status === "ok" ? state.data.items : null; return (

Event log

- {error &&

{error}

} + {state.status === "loading" && } + {state.status === "error" && ( +

+ {state.error} +

+ )} {items && items.length === 0 && ( -

No events.

+

+ No events. +

)} {items && items.length > 0 && (
    - {items.map((e) => ( + {items.map((event) => (
  1. - {e.type} - {new Date(e.ts).toISOString()} + {event.type} + {new Date(event.ts).toISOString()}
    -                {JSON.stringify(e.payload, null, 2)}
    +                {JSON.stringify(event.payload, null, 2)}
                   
  2. ))} diff --git a/src/app/pairs/page.test.tsx b/src/app/pairs/page.test.tsx new file mode 100644 index 0000000..ec9ff12 --- /dev/null +++ b/src/app/pairs/page.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import PairsPage from "./page"; + +function jsonResponse(body: unknown, init: Partial = {}) { + 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(); + + 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(); + + expect(await screen.findByText("USDC -> EURC")).toBeInTheDocument(); +}); + +it("renders the empty state", async () => { + globalThis.fetch = jest.fn().mockResolvedValue(jsonResponse({ pairs: [] })); + + render(); + + 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(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "pairs unavailable" + ); +}); diff --git a/src/app/pairs/page.tsx b/src/app/pairs/page.tsx index 1835140..6ec64cf 100644 --- a/src/app/pairs/page.tsx +++ b/src/app/pairs/page.tsx @@ -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(null); - const [error, setError] = useState(null); - - useEffect(() => { - apiGet<{ pairs: Pair[] }>("/api/v1/pairs") - .then((b) => setPairs(b.pairs)) - .catch((e) => setError(e.message)); - }, []); + const state = useApi("/api/v1/pairs"); + const pairs = state.status === "ok" ? state.data.pairs : null; return (
    - {error && ( + {state.status === "loading" && } + {state.status === "error" && (

    - {error} + {state.error}

    )} - {!pairs && !error &&

    Loading…

    } {pairs && pairs.length === 0 && (

    No pairs registered yet. @@ -45,8 +43,11 @@ export default function PairsPage() { {pairs && pairs.length > 0 && (

      {pairs.map((p) => ( -
    • - {p.source} → {p.destination} +
    • + {p.source} -> {p.destination}
    • ))}
    diff --git a/src/lib/__tests__/useApi.test.tsx b/src/lib/__tests__/useApi.test.tsx new file mode 100644 index 0000000..8c89d3b --- /dev/null +++ b/src/lib/__tests__/useApi.test.tsx @@ -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

    Loading

    ; + if (state.status === "error") return

    {state.error}

    ; + return

    {state.data.value}

    ; +} + +function jsonResponse(body: unknown, init: Partial = {}) { + 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(); + + 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(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "request failed" + ); +}); + +it("does not fetch when path is null", () => { + globalThis.fetch = jest.fn(); + + render(); + + 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((resolve) => { + resolveFetch = resolve; + }); + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + globalThis.fetch = jest.fn().mockReturnValue(fetchPromise); + + const { unmount } = render(); + unmount(); + + await act(async () => { + resolveFetch(jsonResponse({ value: "late" })); + await fetchPromise; + }); + + await waitFor(() => { + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +});