From a3e683347747ed5f38180d363ed07566d0b5ffcf Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 18:41:09 -0400 Subject: [PATCH] feat(services): model dashboard loading, empty, error and success as one state --- src/app/services/async-state.test.tsx | 103 +++++++++++++++++++++ src/app/services/page.tsx | 126 ++++++++++++++------------ src/lib/servicesFetchState.test.ts | 39 ++++++++ src/lib/servicesFetchState.ts | 63 +++++++++++++ 4 files changed, 272 insertions(+), 59 deletions(-) create mode 100644 src/app/services/async-state.test.tsx create mode 100644 src/lib/servicesFetchState.test.ts create mode 100644 src/lib/servicesFetchState.ts diff --git a/src/app/services/async-state.test.tsx b/src/app/services/async-state.test.tsx new file mode 100644 index 0000000..96b99d3 --- /dev/null +++ b/src/app/services/async-state.test.tsx @@ -0,0 +1,103 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ServicesPage from "./page"; +import { apiGet } from "../../lib/apiClient"; +import { ToastProvider } from "../../components/ToastProvider"; + +jest.mock("../../lib/apiClient", () => ({ apiGet: jest.fn() })); +const request = apiGet as jest.MockedFunction; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function mount() { + return render( + + ); +} + +describe("services async rendering and retry", () => { + beforeEach(() => { + request.mockReset(); + window.history.replaceState(null, "", "/services"); + }); + + it("renders only the loader while a request is pending", () => { + request.mockReturnValueOnce(new Promise(() => undefined) as never); + mount(); + expect(screen.getByRole("status")).toHaveTextContent("Loading services"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("No services registered yet.")).not.toBeInTheDocument(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + + it("renders only the empty state when there are no results", async () => { + request.mockResolvedValueOnce({ services: [], page: 1, pageCount: 1 } as never); + mount(); + expect(await screen.findByText("No services registered yet.")).toBeInTheDocument(); + expect(screen.getByText("No services available")).toHaveAttribute("aria-live", "polite"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("Loading services")).not.toBeInTheDocument(); + }); + + it("renders a sanitized typed error with retry but no stale content", async () => { + request.mockRejectedValueOnce(new Error("SECRET_INTERNAL_DATABASE_DETAILS")); + mount(); + const error = await screen.findByRole("alert"); + expect(error).toHaveTextContent("SERVICES_FETCH_FAILED"); + expect(error).not.toHaveTextContent("SECRET_INTERNAL_DATABASE_DETAILS"); + expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + + it("retry returns to loading and then shows success without error", async () => { + const next = deferred<{ services: { serviceId: string; priceStroops: number }[] }>(); + request.mockRejectedValueOnce(new Error("network")); + request.mockReturnValueOnce(next.promise as never); + mount(); + await screen.findByRole("alert"); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByRole("status")).toHaveTextContent("Loading services"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await act(async () => next.resolve({ services: [{ serviceId: "recovered", priceStroops: 1 }] })); + expect(await screen.findByRole("link", { name: /recovered/i })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("renders only success data and live announcement", async () => { + request.mockResolvedValueOnce({ + services: [{ serviceId: "ready", priceStroops: 4 }], + page: 1, pageCount: 1, + } as never); + mount(); + expect(await screen.findByRole("link", { name: /ready/i })).toBeInTheDocument(); + expect(screen.getByText("Loaded 1 services")).toHaveAttribute("aria-live", "polite"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("No services registered yet.")).not.toBeInTheDocument(); + }); + + it("drops previous page content before the next page has resolved", async () => { + const next = deferred<{ services: { serviceId: string; priceStroops: number }[]; page: number; pageCount: number }>(); + request.mockResolvedValueOnce({ + services: [{ serviceId: "old", priceStroops: 4 }], + page: 1, pageCount: 2, + } as never).mockReturnValueOnce(next.promise as never); + mount(); + await screen.findByRole("link", { name: /old/i }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByRole("status")).toHaveTextContent("Loading services"); + expect(screen.queryByRole("link", { name: /old/i })).not.toBeInTheDocument(); + await act(async () => next.resolve({ + services: [{ serviceId: "new", priceStroops: 5 }], page: 2, pageCount: 2, + })); + expect(await screen.findByRole("link", { name: /new/i })).toBeInTheDocument(); + await waitFor(() => expect(request).toHaveBeenLastCalledWith("/api/v1/services?page=2&limit=25")); + }); +}); diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..08fcd8b 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -11,14 +11,14 @@ import { Spinner } from "@/components/Spinner"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; import { useClipboard } from "@/lib/useClipboard"; - -type Service = { serviceId: string; priceStroops: number; createdAt?: number | string | null }; -type ServicesResponse = { - services?: Service[]; - items?: Service[]; - page?: number; - pageCount?: number; -}; +import { + type ServiceRow as Service, + type ServicesFetchResponse, + type ServicesFetchState, + loadingServices, + resolveServices, + failedServices, +} from "@/lib/servicesFetchState"; type SortKey = "name" | "price" | "created" | ""; type SortDir = "asc" | "desc"; @@ -178,17 +178,19 @@ export function ServiceCopyButton({ serviceId }: { serviceId: string }) { } export default function ServicesPage() { - const [services, setServices] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(1); + // One discriminated union prevents stale content appearing behind errors/loading. + const [servicesState, setServicesState] = useState(loadingServices); const [requestedPage, setRequestedPage] = useState(1); - const [pageCount, setPageCount] = useState(1); + const [retryGeneration, setRetryGeneration] = useState(0); const [{ key: sortKey, dir: sortDir }, setSort] = useState<{ key: SortKey; dir: SortDir }>( getInitialSort ); - const sortedServices = useSorted(services, sortKey, sortDir); + const sortedServices = useSorted( + servicesState.status === "success" ? servicesState.services : null, + sortKey, + sortDir + ); const handleSort = (key: SortKey) => { const next: { key: SortKey; dir: SortDir } = @@ -212,47 +214,37 @@ export default function ServicesPage() { }, []); const onPageChange = (nextPage: number) => { - setLoading(true); - setError(null); - setServices(null); + if (!Number.isSafeInteger(nextPage) || nextPage < 1) return; + setServicesState(loadingServices()); setRequestedPage(nextPage); }; + const retry = () => { + // Refetch even when the requested page has not changed. + setServicesState(loadingServices()); + setRetryGeneration((generation) => generation + 1); + }; + useEffect(() => { + // Cancellation keeps an older request from committing after page/retry. let cancelled = false; + setServicesState(loadingServices()); - apiGet( + apiGet( `/api/v1/services?page=${requestedPage}&limit=${PAGE_SIZE}` ) .then((body) => { - if (cancelled) return; - - const nextServices = body.services ?? body.items ?? []; - const nextPageCount = Math.max(body.pageCount ?? 1, 1); - const nextPage = Math.min( - Math.max(body.page ?? requestedPage, 1), - nextPageCount - ); - - setServices(nextServices); - setPageCount(nextPageCount); - setPage(nextPage); - }) - .catch((e) => { - if (cancelled) return; - setError(e.message ?? "failed to load"); - setPageCount(1); + if (!cancelled) setServicesState(resolveServices(body, requestedPage)); }) - .finally(() => { - if (!cancelled) { - setLoading(false); - } + .catch(() => { + // Do not expose network/server exceptions or internal endpoints to users. + if (!cancelled) setServicesState(failedServices()); }); return () => { cancelled = true; }; - }, [requestedPage]); + }, [requestedPage, retryGeneration]); return ( @@ -265,27 +257,42 @@ export default function ServicesPage() { New service - - {loading && ( + {servicesState.status === "error" && ( + + )} + {servicesState.status === "loading" && (
)} - {!loading && services && services.length === 0 && ( - - New service - - } - /> + {servicesState.status === "empty" && ( + <> + + No services available + + + New service + + } + /> + )} - {!loading && services && services.length > 0 && ( + {servicesState.status === "success" && ( + <> + + { `Loaded ${servicesState.services.length} services` } +
    {sortedServices?.map((s) => (
  • ))}
+ )} - {!loading && !error && ( + {(servicesState.status === "success" || servicesState.status === "empty") && ( )} diff --git a/src/lib/servicesFetchState.test.ts b/src/lib/servicesFetchState.test.ts new file mode 100644 index 0000000..5e175c6 --- /dev/null +++ b/src/lib/servicesFetchState.test.ts @@ -0,0 +1,39 @@ +import { + failedServices, + loadingServices, + resolveServices, +} from "./servicesFetchState"; + +describe("services fetch state machine", () => { + it("starts loading without data or hidden error payloads", () => { + expect(loadingServices()).toEqual({ status: "loading" }); + }); + + it("maps an empty response exclusively to empty", () => { + expect(resolveServices({ services: [], page: 1, pageCount: 2 }, 1)).toEqual({ + status: "empty", page: 1, pageCount: 2, + }); + }); + + it("maps service rows to a success-only payload and clamps pagination", () => { + const services = [{ serviceId: "svc-123", priceStroops: 12 }]; + expect(resolveServices({ items: services, page: 100, pageCount: 2 }, 2)).toEqual({ + status: "success", services, page: 2, pageCount: 2, + }); + }); + + it("fails closed on malformed payloads rather than rendering invalid rows", () => { + expect(resolveServices({ services: [{}] } as never, 1)).toEqual(failedServices()); + expect(resolveServices({ services: [null] } as never, 1)).toEqual(failedServices()); + }); + + it("uses a stable typed public error without propagating server internals", () => { + expect(failedServices()).toEqual({ + status: "error", + error: { + code: "SERVICES_FETCH_FAILED", + message: "Services are temporarily unavailable. Please try again.", + }, + }); + }); +}); diff --git a/src/lib/servicesFetchState.ts b/src/lib/servicesFetchState.ts new file mode 100644 index 0000000..167f746 --- /dev/null +++ b/src/lib/servicesFetchState.ts @@ -0,0 +1,63 @@ +/** Isolated loading state shared by the services dashboard and its focused tests. + * A single tagged union makes loading / empty / error / success exclusive. + * No raw response, token or server error is exposed in the error state. + */ +export type ServiceRow = { + serviceId: string; + priceStroops: number; + createdAt?: number | string | null; +}; + +export type ServicesFetchResponse = { + services?: ServiceRow[]; + items?: ServiceRow[]; + page?: number; + pageCount?: number; +}; + +type LoadedPage = { page: number; pageCount: number }; + +export type ServicesFetchState = + | { status: "loading" } + | { status: "empty" } & LoadedPage + | { status: "error"; error: { code: "SERVICES_FETCH_FAILED"; message: string } } + | ({ status: "success"; services: ServiceRow[] } & LoadedPage); + +export function loadingServices(): ServicesFetchState { + return { status: "loading" }; +} + +export function failedServices(): ServicesFetchState { + return { + status: "error", + error: { + code: "SERVICES_FETCH_FAILED", + message: "Services are temporarily unavailable. Please try again.", + }, + }; +} + +function positivePage(value: number | undefined, fallback: number): number { + return Number.isSafeInteger(value) && Number(value) > 0 + ? Number(value) + : fallback; +} + +export function resolveServices( + response: ServicesFetchResponse, + requestedPage: number +): ServicesFetchState { + const rows = response?.services ?? response?.items ?? []; + if (!Array.isArray(rows) || !rows.every( + (row) => row && typeof row.serviceId === "string" && + typeof row.priceStroops === "number" && Number.isFinite(row.priceStroops) + )) { + return failedServices(); + } + + const pageCount = positivePage(response.pageCount, 1); + const page = Math.min(positivePage(response.page, positivePage(requestedPage, 1)), pageCount); + return rows.length === 0 + ? { status: "empty", page, pageCount } + : { status: "success", services: rows, page, pageCount }; +}