Files
uupl-smart-contract/web/src/hooks/useBankDashboard.test.tsx
T

226 lines
8.8 KiB
TypeScript

import { focusManager, onlineManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import type { Address } from "viem";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
import { useBankDashboard } from "./useBankDashboard";
let notifyBlock: ((blockNumber: bigint) => void) | undefined;
vi.mock("wagmi", () => ({
useWatchBlockNumber: (options: { onBlockNumber?: (blockNumber: bigint) => void }) => {
notifyBlock = options.onBlockNumber;
},
}));
const address = (digit: string) => `0x${digit.repeat(40)}` as Address;
const manifest: DeploymentManifest = {
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 3n,
rpcUrl: "http://127.0.0.1:8545",
token: address("1"),
proxy: address("2"),
implementation: address("3"),
owner: address("4"),
actors: [{ label: "Alice", address: address("5") }],
};
const manifestJson = { ...manifest, deploymentBlock: 3 };
const snapshot: DashboardSnapshot = {
blockNumber: 12n,
synchronizedAt: new Date("2026-08-21T10:00:00.000Z"),
version: 1,
paused: false,
asset: manifest.token,
owner: manifest.owner,
proxy: manifest.proxy,
implementation: manifest.implementation,
reserves: 900_000_000n,
liabilities: 900_000_000n,
surplus: 0n,
actors: [{ label: "Alice", address: address("5"), balance: 900_000_000n }],
activity: [],
diagnostics: [],
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function createWrapper(client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe("useBankDashboard", () => {
beforeEach(() => {
notifyBlock = undefined;
focusManager.setFocused(true);
onlineManager.setOnline(true);
});
afterEach(() => {
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
});
it("starts in loading while the manifest and first reconciled snapshot are pending", () => {
const pendingManifest = deferred<unknown>();
const { result } = renderHook(() => useBankDashboard({
loadManifest: () => pendingManifest.promise,
loader: vi.fn(),
}), { wrapper: createWrapper() });
expect(result.current.status).toBe("loading");
});
it("becomes ready with the synchronized block and time from the successful snapshot", async () => {
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader: async () => snapshot,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("ready"));
if (result.current.status !== "ready") throw new Error("expected ready dashboard");
expect(result.current.snapshot.blockNumber).toBe(12n);
expect(result.current.snapshot.synchronizedAt.toISOString()).toBe("2026-08-21T10:00:00.000Z");
});
it("retains the last successful snapshot as stale when a later reconciliation fails", async () => {
const loader = vi.fn()
.mockResolvedValueOnce(snapshot)
.mockRejectedValueOnce(new Error("RPC timeout"));
const clock = vi.fn(() => new Date("2026-08-21T10:05:00.000Z"));
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader,
now: clock,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("ready"));
act(() => notifyBlock?.(13n));
await waitFor(() => expect(result.current.status).toBe("stale"));
if (result.current.status !== "stale") throw new Error("expected stale dashboard");
expect(result.current.snapshot).toBe(snapshot);
expect(result.current.failedAt.toISOString()).toBe("2026-08-21T10:05:00.000Z");
expect(result.current.error).toContain("RPC timeout");
});
it("reports an initial RPC failure as disconnected without inventing a snapshot", async () => {
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader: async () => { throw new Error("connection refused"); },
now: () => new Date("2026-08-21T10:06:00.000Z"),
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("disconnected"));
if (result.current.status !== "disconnected") throw new Error("expected disconnected dashboard");
expect(result.current.snapshot).toBeUndefined();
expect(result.current.error).toContain("connection refused");
});
it("makes an invalid manifest terminal and never calls the snapshot loader", async () => {
const loader = vi.fn();
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => ({ network: "anvil" }),
loader,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("invalid-manifest"));
expect(loader).not.toHaveBeenCalled();
act(() => notifyBlock?.(14n));
expect(loader).not.toHaveBeenCalled();
});
it("makes a manifest/network mismatch terminal and stops block reconciliation", async () => {
const loader = vi.fn().mockRejectedValue(new Error(
"endpoint chain ID 84532 does not match manifest chain ID 31337",
));
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("chain-mismatch"));
act(() => notifyBlock?.(14n));
await Promise.resolve();
expect(loader).toHaveBeenCalledTimes(1);
});
it("does not retry a terminal manifest-fetch failure on focus, reconnect, or remount", async () => {
const loadManifest = vi.fn().mockRejectedValue(new Error("manifest request failed"));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } } });
const wrapper = createWrapper(client);
const options = { loadManifest, loader: vi.fn() };
const first = renderHook(() => useBankDashboard(options), { wrapper });
await waitFor(() => expect(first.result.current.status).toBe("invalid-manifest"));
await act(async () => {
focusManager.setFocused(false);
focusManager.setFocused(true);
onlineManager.setOnline(false);
onlineManager.setOnline(true);
await new Promise((resolve) => setTimeout(resolve, 0));
});
first.unmount();
const second = renderHook(() => useBankDashboard(options), { wrapper });
await waitFor(() => expect(second.result.current.status).toBe("invalid-manifest"));
expect(loadManifest).toHaveBeenCalledTimes(1);
});
it("does not retry a terminal chain mismatch on focus, reconnect, or remount", async () => {
const loader = vi.fn().mockRejectedValue(new Error(
"endpoint chain ID 84532 does not match manifest chain ID 31337",
));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } } });
const wrapper = createWrapper(client);
const options = { loadManifest: async () => manifestJson, loader };
const first = renderHook(() => useBankDashboard(options), { wrapper });
await waitFor(() => expect(first.result.current.status).toBe("chain-mismatch"));
await act(async () => {
focusManager.setFocused(false);
focusManager.setFocused(true);
onlineManager.setOnline(false);
onlineManager.setOnline(true);
await new Promise((resolve) => setTimeout(resolve, 0));
});
first.unmount();
const second = renderHook(() => useBankDashboard(options), { wrapper });
await waitFor(() => expect(second.result.current.status).toBe("chain-mismatch"));
expect(loader).toHaveBeenCalledTimes(1);
});
it("reconciles exactly once when a new watched block arrives", async () => {
const nextSnapshot = { ...snapshot, blockNumber: 13n, synchronizedAt: new Date("2026-08-21T10:01:00.000Z") };
const loader = vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(nextSnapshot);
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("ready"));
act(() => {
notifyBlock?.(13n);
notifyBlock?.(13n);
});
await waitFor(() => {
expect(loader).toHaveBeenCalledTimes(2);
expect(result.current.status === "ready" && result.current.snapshot.blockNumber).toBe(13n);
});
});
});