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
103 changes: 103 additions & 0 deletions src/app/services/async-state.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof apiGet>;

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}

function mount() {
return render(
<ToastProvider><ServicesPage /></ToastProvider>
);
}

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"));
});
});
126 changes: 67 additions & 59 deletions src/app/services/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -178,17 +178,19 @@ export function ServiceCopyButton({ serviceId }: { serviceId: string }) {
}

export default function ServicesPage() {
const [services, setServices] = useState<Service[] | null>(null);
const [error, setError] = useState<string | null>(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<ServicesFetchState>(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 } =
Expand All @@ -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<ServicesResponse>(
apiGet<ServicesFetchResponse>(
`/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 (
<PageShell>
Expand All @@ -265,27 +257,42 @@ export default function ServicesPage() {
New service
</Link>
</header>
<ErrorMessage title="Failed to load services" detail={error} />
{loading && (
{servicesState.status === "error" && (
<ErrorMessage
title="Failed to load services"
detail={`${servicesState.error.code}: ${servicesState.error.message}`}
onRetry={retry}
/>
)}
{servicesState.status === "loading" && (
<div className="flex justify-center py-10">
<Spinner label="Loading services" />
</div>
)}
{!loading && services && services.length === 0 && (
<EmptyState
title="No services registered yet."
description="Create the first service to start tracking request pricing."
action={
<Link
href="/services/new"
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-white dark:text-black"
>
New service
</Link>
}
/>
{servicesState.status === "empty" && (
<>
<span aria-live="polite" aria-atomic="true" className="sr-only">
No services available
</span>
<EmptyState
title="No services registered yet."
description="Create the first service to start tracking request pricing."
action={
<Link
href="/services/new"
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-white dark:text-black"
>
New service
</Link>
}
/>
</>
)}
{!loading && services && services.length > 0 && (
{servicesState.status === "success" && (
<>
<span aria-live="polite" aria-atomic="true" className="sr-only">
{ `Loaded ${servicesState.services.length} services` }
</span>
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{sortedServices?.map((s) => (
<li
Expand All @@ -311,11 +318,12 @@ export default function ServicesPage() {
</li>
))}
</ul>
</>
)}
{!loading && !error && (
{(servicesState.status === "success" || servicesState.status === "empty") && (
<Pagination
page={page}
pageCount={pageCount}
page={servicesState.page}
pageCount={servicesState.pageCount}
onChange={onPageChange}
/>
)}
Expand Down
39 changes: 39 additions & 0 deletions src/lib/servicesFetchState.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
});
});
});
63 changes: 63 additions & 0 deletions src/lib/servicesFetchState.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading