feat: add read-only bank operations console
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { 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 { 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() {
|
||||
const 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;
|
||||
});
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user