Files
diegosouzapw--omniroute/tests/unit/ui/use-pools-usage-aggregate.test.tsx
2026-07-13 13:39:12 +08:00

216 lines
7.4 KiB
TypeScript

// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mock fetch globally ────────────────────────────────────────────────────
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
// ── Lazy import after mocks ────────────────────────────────────────────────
const { usePoolsUsageAggregate } = await import(
"../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate"
);
// ── Helpers ───────────────────────────────────────────────────────────────
type AggregateState = {
avgUtilizationPercent: number;
borrowingKeyCount: number;
loading: boolean;
error: string | null;
};
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
// Mutable ref accessible from outside React render — updated via useEffect to avoid the
// react-hooks/globals lint rule that bans direct assignment inside render bodies.
let capturedState: AggregateState | null = null;
function TestComponent({
pools,
onState,
}: {
pools: { id: string; allocations: unknown[] }[];
onState: (s: AggregateState) => void;
}) {
const state = usePoolsUsageAggregate(pools as any);
// Use useEffect to capture state without triggering react-hooks/globals
const { useEffect } = React;
useEffect(() => {
onState(state);
});
return null;
}
async function renderHook(pools: { id: string; allocations: unknown[] }[]) {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
capturedState = null;
await act(async () => {
root = createRoot(container!);
root.render(
<TestComponent
pools={pools}
onState={(s) => {
capturedState = s;
}}
/>
);
});
}
async function waitFor(fn: () => boolean, timeout = 3000) {
const start = Date.now();
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 20));
}
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("usePoolsUsageAggregate", { timeout: 15000 }, () => {
beforeEach(() => {
vi.clearAllMocks();
capturedState = null;
});
afterEach(() => {
if (root && container) {
act(() => root!.unmount());
}
container?.remove();
container = null;
root = null;
capturedState = null;
});
// ── Scenario 1: no pools ────────────────────────────────────────────────
it("returns zeros immediately and does NOT call fetch when pools is empty", async () => {
await renderHook([]);
// Should be synchronously settled after act
expect(capturedState).not.toBeNull();
expect(capturedState!.avgUtilizationPercent).toBe(0);
expect(capturedState!.borrowingKeyCount).toBe(0);
expect(capturedState!.loading).toBe(false);
expect(capturedState!.error).toBeNull();
expect(mockFetch).not.toHaveBeenCalled();
});
// ── Scenario 2: 2 pools, fetch resolves with snapshots ──────────────────
it("aggregates avgUtilizationPercent and borrowingKeyCount across 2 pools", async () => {
// Pool 1: consumedTotal=50, limit=100 → util=50%; 1 borrowing key
const pool1Response = {
usage: {
dimensions: [
{
limit: 100,
consumedTotal: 50,
perKey: [{ borrowing: true }, { borrowing: false }],
},
],
},
};
// Pool 2: consumedTotal=75, limit=100 → util=75%; 2 borrowing keys
const pool2Response = {
usage: {
dimensions: [
{
limit: 100,
consumedTotal: 75,
perKey: [{ borrowing: true }, { borrowing: true }],
},
],
},
};
mockFetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(pool1Response) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(pool2Response) });
const pools = [
{ id: "pool_1", allocations: [] },
{ id: "pool_2", allocations: [] },
];
await renderHook(pools);
await act(async () => {
await waitFor(() => capturedState?.loading === false);
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith("/api/quota/pools/pool_1/usage");
expect(mockFetch).toHaveBeenCalledWith("/api/quota/pools/pool_2/usage");
// avgUtilizationPercent = (50 + 75) / 2 = 62.5
expect(capturedState!.avgUtilizationPercent).toBeCloseTo(62.5);
// borrowingKeyCount = 1 + 2 = 3
expect(capturedState!.borrowingKeyCount).toBe(3);
expect(capturedState!.loading).toBe(false);
expect(capturedState!.error).toBeNull();
});
// ── Scenario 3: fetch failure → fail-soft ───────────────────────────────
it("sets error and loading=false on fetch failure (fail-soft, does not throw)", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const pools = [{ id: "pool_fail", allocations: [] }];
await renderHook(pools);
await act(async () => {
await waitFor(() => capturedState?.loading === false);
});
expect(capturedState!.loading).toBe(false);
expect(capturedState!.error).toBeTruthy();
expect(capturedState!.error).toContain("Network error");
expect(capturedState!.avgUtilizationPercent).toBe(0);
expect(capturedState!.borrowingKeyCount).toBe(0);
});
// ── Scenario 4: dimensions with limit === 0 are skipped ─────────────────
it("ignores dimensions with limit === 0 to avoid division by zero", async () => {
const poolResponse = {
usage: {
dimensions: [
{
// limit=0 — must be skipped (no util contribution)
limit: 0,
consumedTotal: 999,
perKey: [{ borrowing: true }],
},
{
// limit=100, consumed=40 → util=40%; no borrowing
limit: 100,
consumedTotal: 40,
perKey: [{ borrowing: false }],
},
],
},
};
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(poolResponse) });
const pools = [{ id: "pool_zero", allocations: [] }];
await renderHook(pools);
await act(async () => {
await waitFor(() => capturedState?.loading === false);
});
// Only the valid dimension (limit=100) contributes to util
expect(capturedState!.avgUtilizationPercent).toBeCloseTo(40);
// borrowing from the limit=0 dimension still counts (perKey loop is independent)
expect(capturedState!.borrowingKeyCount).toBe(1);
expect(capturedState!.loading).toBe(false);
expect(capturedState!.error).toBeNull();
});
});