226 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|