mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
test(ui): cover quota-share page, components, hooks, plans config + sidebar (B/F9)
This commit is contained in:
44
tests/unit/sidebar-costs-quota-plans.test.ts
Normal file
44
tests/unit/sidebar-costs-quota-plans.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
|
||||
|
||||
function sectionItems(sectionId: string) {
|
||||
const section = sidebarVisibility.SIDEBAR_SECTIONS.find((s) => s.id === sectionId);
|
||||
assert.ok(section, `expected section "${sectionId}" to exist`);
|
||||
return sidebarVisibility.getSectionItems(section);
|
||||
}
|
||||
|
||||
test("HIDEABLE_SIDEBAR_ITEM_IDS contains costs-quota-plans", () => {
|
||||
assert.ok(
|
||||
(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("costs-quota-plans"),
|
||||
"costs-quota-plans must be in HIDEABLE_SIDEBAR_ITEM_IDS"
|
||||
);
|
||||
});
|
||||
|
||||
test("HIDEABLE_SIDEBAR_ITEM_IDS: costs-quota-plans appears after costs-quota-share", () => {
|
||||
const ids = sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[];
|
||||
const qsIdx = ids.indexOf("costs-quota-share");
|
||||
const qpIdx = ids.indexOf("costs-quota-plans");
|
||||
assert.ok(qsIdx !== -1, "costs-quota-share must exist");
|
||||
assert.ok(qpIdx !== -1, "costs-quota-plans must exist");
|
||||
assert.ok(qpIdx > qsIdx, "costs-quota-plans must come after costs-quota-share");
|
||||
});
|
||||
|
||||
test("costs section has 5 items including costs-quota-plans at end", () => {
|
||||
const items = sectionItems("costs");
|
||||
const ids = items.map((i) => i.id);
|
||||
assert.ok(ids.includes("costs-quota-plans"), "costs section must include costs-quota-plans");
|
||||
assert.strictEqual(ids[ids.length - 1], "costs-quota-plans", "costs-quota-plans must be last");
|
||||
assert.strictEqual(ids.length, 5, "costs section must have exactly 5 items");
|
||||
});
|
||||
|
||||
test("costs-quota-plans has correct href and icon", () => {
|
||||
const items = sectionItems("costs");
|
||||
const item = items.find((i) => i.id === "costs-quota-plans");
|
||||
assert.ok(item, "costs-quota-plans item must exist");
|
||||
assert.strictEqual(item.href, "/dashboard/costs/quota-share/plans");
|
||||
assert.strictEqual(item.icon, "fact_check");
|
||||
assert.strictEqual(item.i18nKey, "costsQuotaPlans");
|
||||
assert.strictEqual(item.subtitleKey, "costsQuotaPlansSubtitle");
|
||||
});
|
||||
89
tests/unit/ui/allocation-table.test.tsx
Normal file
89
tests/unit/ui/allocation-table.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AllocationTable } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable"
|
||||
);
|
||||
|
||||
const ALLOCATIONS = [
|
||||
{ apiKeyId: "key_1", weight: 60, policy: "hard" as const },
|
||||
{ apiKeyId: "key_2", weight: 40, policy: "soft" as const },
|
||||
];
|
||||
|
||||
const KEY_LABELS: Record<string, string> = { key_1: "KeyOne", key_2: "KeyTwo" };
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function render(props: Parameters<typeof AllocationTable>[0]) {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<AllocationTable {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("AllocationTable", { timeout: 10000 }, () => {
|
||||
afterEach(() => {
|
||||
if (root && container) act(() => root!.unmount());
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders empty state when no allocations", async () => {
|
||||
await render({ allocations: [], usage: null, keyLabels: {} });
|
||||
expect(document.body.innerHTML).toContain("noAllocations");
|
||||
});
|
||||
|
||||
it("renders key labels", async () => {
|
||||
await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("KeyOne");
|
||||
expect(document.body.innerHTML).toContain("KeyTwo");
|
||||
});
|
||||
|
||||
it("renders weights correctly", async () => {
|
||||
await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("60%");
|
||||
expect(document.body.innerHTML).toContain("40%");
|
||||
});
|
||||
|
||||
it("renders policy badges", async () => {
|
||||
await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("hard");
|
||||
expect(document.body.innerHTML).toContain("soft");
|
||||
});
|
||||
|
||||
it("renders consumed values from usage perKey data", async () => {
|
||||
const usage = {
|
||||
dimensions: [
|
||||
{
|
||||
unit: "tokens",
|
||||
window: "daily",
|
||||
limit: 1000,
|
||||
consumedTotal: 400,
|
||||
perKey: [
|
||||
{ apiKeyId: "key_1", consumed: 300, fairShare: 600, deficit: -300, borrowing: false },
|
||||
{ apiKeyId: "key_2", consumed: 100, fairShare: 400, deficit: 300, borrowing: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
burnRate: null,
|
||||
};
|
||||
await render({ allocations: ALLOCATIONS, usage: usage as never, keyLabels: KEY_LABELS });
|
||||
expect(document.body.innerHTML).toContain("300");
|
||||
expect(document.body.innerHTML).toContain("100");
|
||||
// borrowing indicator for key_2
|
||||
expect(document.body.innerHTML).toContain("borrowingIndicator");
|
||||
});
|
||||
});
|
||||
70
tests/unit/ui/burn-rate-chart.test.tsx
Normal file
70
tests/unit/ui/burn-rate-chart.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Stub next/dynamic — returns null component (recharts not needed in tests)
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => () => null,
|
||||
}));
|
||||
|
||||
const { default: BurnRateChart } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart"
|
||||
);
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function render(props: Parameters<typeof BurnRateChart>[0]) {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<BurnRateChart {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("BurnRateChart", { timeout: 10000 }, () => {
|
||||
afterEach(() => {
|
||||
if (root && container) act(() => root!.unmount());
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders no-data state when usage is null", async () => {
|
||||
await render({ usage: null });
|
||||
expect(document.body.innerHTML).toContain("burnRateTitle");
|
||||
expect(document.body.innerHTML).toContain("no data");
|
||||
});
|
||||
|
||||
it("renders no-data state when burnRate is falsy", async () => {
|
||||
const usage = {
|
||||
dimensions: [],
|
||||
burnRate: null,
|
||||
};
|
||||
await render({ usage: usage as never });
|
||||
expect(document.body.innerHTML).toContain("no data");
|
||||
});
|
||||
|
||||
it("renders chart when usage has burnRate data", async () => {
|
||||
const usage = {
|
||||
dimensions: [
|
||||
{ unit: "tokens", window: "daily", limit: 100000, consumedTotal: 30000, perKey: [] },
|
||||
],
|
||||
burnRate: { tokensPerSecond: 10, timeToExhaustionMs: 7_000_000 },
|
||||
};
|
||||
await render({ usage: usage as never });
|
||||
// Should not show no-data message
|
||||
expect(document.body.innerHTML).not.toContain("no data yet");
|
||||
// Should show exhaustion label
|
||||
expect(document.body.innerHTML).toContain("burnRateExhaustsIn");
|
||||
});
|
||||
});
|
||||
141
tests/unit/ui/pool-card.test.tsx
Normal file
141
tests/unit/ui/pool-card.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// @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";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/Card", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="card">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/ProviderIcon", () => ({
|
||||
default: ({ providerId }: { providerId: string }) => (
|
||||
<span data-testid={`provider-icon-${providerId}`} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub sub-components
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/DimensionBar",
|
||||
() => ({ default: ({ dimension }: { dimension: { unit: string } }) => <div data-testid="dim-bar">{dimension.unit}</div> })
|
||||
);
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable",
|
||||
() => ({ default: () => <div data-testid="alloc-table" /> })
|
||||
);
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart",
|
||||
() => ({ default: () => <div data-testid="burn-rate-chart" /> })
|
||||
);
|
||||
|
||||
const { default: PoolCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard"
|
||||
);
|
||||
|
||||
const MOCK_POOL = {
|
||||
id: "pool_1",
|
||||
connectionId: "conn_1",
|
||||
name: "Test Pool",
|
||||
createdAt: new Date().toISOString(),
|
||||
allocations: [{ apiKeyId: "key_1", weight: 60, policy: "hard" as const }],
|
||||
};
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function renderCard(usage = null as null | object) {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(
|
||||
<PoolCard
|
||||
pool={MOCK_POOL}
|
||||
usage={usage as never}
|
||||
keyLabels={{ key_1: "MyKey" }}
|
||||
connectionLabel="My Conn"
|
||||
provider="openai"
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("PoolCard", { timeout: 10000 }, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root && container) {
|
||||
act(() => root!.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders pool name and connection label", async () => {
|
||||
await renderCard();
|
||||
expect(document.body.innerHTML).toContain("Test Pool");
|
||||
expect(document.body.innerHTML).toContain("My Conn");
|
||||
});
|
||||
|
||||
it("renders AllocationTable", async () => {
|
||||
await renderCard();
|
||||
expect(document.querySelector("[data-testid='alloc-table']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders DimensionBar when usage has dimensions", async () => {
|
||||
const usage = {
|
||||
dimensions: [{ unit: "tokens", window: "daily", limit: 1000, consumedTotal: 500, perKey: [] }],
|
||||
burnRate: null,
|
||||
};
|
||||
await renderCard(usage);
|
||||
expect(document.querySelector("[data-testid='dim-bar']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders BurnRateChart when usage is non-null", async () => {
|
||||
const usage = {
|
||||
dimensions: [{ unit: "tokens", window: "daily", limit: 1000, consumedTotal: 200, perKey: [] }],
|
||||
burnRate: null,
|
||||
};
|
||||
await renderCard(usage);
|
||||
expect(document.querySelector("[data-testid='burn-rate-chart']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("calls onEdit when edit button clicked", async () => {
|
||||
const onEdit = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(
|
||||
<PoolCard
|
||||
pool={MOCK_POOL}
|
||||
usage={null}
|
||||
keyLabels={{}}
|
||||
connectionLabel="label"
|
||||
provider="openai"
|
||||
onEdit={onEdit}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const editBtn = document.querySelector("button[title='editAllocations']") as HTMLButtonElement;
|
||||
expect(editBtn).not.toBeNull();
|
||||
await act(async () => editBtn.click());
|
||||
expect(onEdit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
134
tests/unit/ui/provider-plan-config.test.tsx
Normal file
134
tests/unit/ui/provider-plan-config.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// @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";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/components/ProviderIcon", () => ({
|
||||
default: () => <span />,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/quota/planRegistry", () => ({
|
||||
knownProviders: () => ["openai", "anthropic"],
|
||||
getKnownPlan: (prov: string) => {
|
||||
if (prov === "openai") {
|
||||
return { dimensions: [{ unit: "tokens", window: "daily", limit: 100000 }] };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const MOCK_CONNECTIONS = [
|
||||
{ id: "conn_1", provider: "openai", name: "GPT Account" },
|
||||
{ id: "conn_2", provider: "anthropic", email: "user@example.com" },
|
||||
];
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const { default: ProviderPlanConfigClient } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient"
|
||||
);
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function renderPage() {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<ProviderPlanConfigClient />);
|
||||
});
|
||||
// Wait for initial fetch effect to resolve
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProviderPlanConfigClient", { timeout: 15000 }, () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (String(url).includes("/api/providers/client")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ connections: MOCK_CONNECTIONS }),
|
||||
} as unknown as Response);
|
||||
}
|
||||
if (String(url).includes("/api/quota/plans")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as unknown as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
} as unknown as Response);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root && container) act(() => root!.unmount());
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the page title", async () => {
|
||||
await renderPage();
|
||||
expect(document.body.innerHTML).toContain("title");
|
||||
});
|
||||
|
||||
it("renders catalog section with known providers", async () => {
|
||||
await renderPage();
|
||||
// catalogTitle key should appear
|
||||
expect(document.body.innerHTML).toContain("catalogTitle");
|
||||
expect(document.body.innerHTML).toContain("openai");
|
||||
});
|
||||
|
||||
it("renders connection selector with options", async () => {
|
||||
await renderPage();
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
expect(select).not.toBeNull();
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("shows right-panel placeholder when no connection selected", async () => {
|
||||
await renderPage();
|
||||
expect(document.body.innerHTML).toContain("unknownProviderNotice");
|
||||
});
|
||||
|
||||
it("renders save button after selecting a connection", async () => {
|
||||
await renderPage();
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
select.value = "conn_1";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(document.body.innerHTML).toContain("saveOverrideButton");
|
||||
});
|
||||
});
|
||||
169
tests/unit/ui/quota-share-page.test.tsx
Normal file
169
tests/unit/ui/quota-share-page.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
// @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";
|
||||
|
||||
// ── i18n stub ──────────────────────────────────────────────────────────────
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── next/dynamic stub ──────────────────────────────────────────────────────
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => () => null,
|
||||
}));
|
||||
|
||||
// ── Shared component stubs ─────────────────────────────────────────────────
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
||||
<button onClick={onClick}>{children}</button>
|
||||
),
|
||||
Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) =>
|
||||
isOpen ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("@/shared/components/Card", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@/shared/components/ProviderIcon", () => ({
|
||||
default: () => <span />,
|
||||
}));
|
||||
|
||||
// ── Pool data ──────────────────────────────────────────────────────────────
|
||||
const MOCK_POOLS = [
|
||||
{
|
||||
id: "pool_1",
|
||||
connectionId: "conn_1",
|
||||
name: "Pool A",
|
||||
createdAt: new Date().toISOString(),
|
||||
allocations: [{ apiKeyId: "key_1", weight: 50, policy: "hard" }],
|
||||
},
|
||||
{
|
||||
id: "pool_2",
|
||||
connectionId: "conn_2",
|
||||
name: "Pool B",
|
||||
createdAt: new Date().toISOString(),
|
||||
allocations: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── usePools mock ──────────────────────────────────────────────────────────
|
||||
const mockMutate = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUsePools = vi.fn(() => ({
|
||||
pools: MOCK_POOLS,
|
||||
loading: false,
|
||||
error: null,
|
||||
mutate: mockMutate,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools",
|
||||
() => ({ usePools: mockUsePools })
|
||||
);
|
||||
|
||||
// ── usePoolUsage mock ──────────────────────────────────────────────────────
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage",
|
||||
() => ({
|
||||
usePoolUsage: () => ({ usage: null, loading: false, error: null }),
|
||||
})
|
||||
);
|
||||
|
||||
// ── useLocalStoragePoolMigration mock ──────────────────────────────────────
|
||||
const mockMigration = vi.fn();
|
||||
vi.mock(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/useLocalStoragePoolMigration",
|
||||
() => ({ useLocalStoragePoolMigration: mockMigration })
|
||||
);
|
||||
|
||||
// ── fetch stub ─────────────────────────────────────────────────────────────
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as unknown as Response)
|
||||
)
|
||||
);
|
||||
|
||||
// ── Lazy import after mocks ────────────────────────────────────────────────
|
||||
const { default: QuotaSharePageClient } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function renderComponent() {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<QuotaSharePageClient />);
|
||||
});
|
||||
}
|
||||
|
||||
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("QuotaSharePageClient", { timeout: 15000 }, () => {
|
||||
beforeEach(() => {
|
||||
mockMigration.mockReset();
|
||||
vi.clearAllMocks();
|
||||
mockUsePools.mockReturnValue({
|
||||
pools: MOCK_POOLS,
|
||||
loading: false,
|
||||
error: null,
|
||||
mutate: mockMutate,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root && container) {
|
||||
act(() => {
|
||||
root!.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders 2 PoolCard components when usePools returns 2 pools", async () => {
|
||||
await renderComponent();
|
||||
await waitFor(() => {
|
||||
// Each PoolCard renders the pool name text
|
||||
return document.body.innerHTML.includes("Pool A");
|
||||
});
|
||||
expect(document.body.innerHTML).toContain("Pool A");
|
||||
expect(document.body.innerHTML).toContain("Pool B");
|
||||
});
|
||||
|
||||
it("renders empty state when pools is empty", async () => {
|
||||
mockUsePools.mockReturnValue({ pools: [], loading: false, error: null, mutate: mockMutate });
|
||||
await renderComponent();
|
||||
await waitFor(() => document.body.innerHTML.includes("emptyTitle"));
|
||||
expect(document.body.innerHTML).toContain("emptyTitle");
|
||||
});
|
||||
|
||||
it("calls useLocalStoragePoolMigration on mount", async () => {
|
||||
await renderComponent();
|
||||
expect(mockMigration).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not contain localStorage references in rendered output", async () => {
|
||||
await renderComponent();
|
||||
expect(document.body.innerHTML).not.toContain("localStorage");
|
||||
expect(document.body.innerHTML).not.toContain("betaPreviewLabel");
|
||||
});
|
||||
});
|
||||
157
tests/unit/ui/use-local-storage-pool-migration.test.tsx
Normal file
157
tests/unit/ui/use-local-storage-pool-migration.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
// @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";
|
||||
|
||||
const {
|
||||
adaptLsPoolToApiSchema,
|
||||
useLocalStoragePoolMigration,
|
||||
} = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/useLocalStoragePoolMigration"
|
||||
);
|
||||
|
||||
// ── Unit tests for adaptLsPoolToApiSchema ─────────────────────────────────
|
||||
|
||||
describe("adaptLsPoolToApiSchema", () => {
|
||||
it("maps connectionId, accountLabel, and allocations", () => {
|
||||
const lsPool = {
|
||||
id: "old_1",
|
||||
connectionId: "conn_abc",
|
||||
accountLabel: "My Account",
|
||||
policy: "soft" as const,
|
||||
allocations: [{ apiKeyId: "k1", percent: 70 }, { apiKeyId: "k2", percent: 30 }],
|
||||
};
|
||||
const result = adaptLsPoolToApiSchema(lsPool);
|
||||
expect(result.connectionId).toBe("conn_abc");
|
||||
expect(result.name).toBe("My Account");
|
||||
expect(result.allocations).toHaveLength(2);
|
||||
expect(result.allocations[0].weight).toBe(70);
|
||||
expect(result.allocations[0].policy).toBe("soft");
|
||||
});
|
||||
|
||||
it("defaults policy to hard for unknown policy values", () => {
|
||||
const lsPool = { connectionId: "c1", policy: "invalid", allocations: [] };
|
||||
const result = adaptLsPoolToApiSchema(lsPool);
|
||||
expect(result.allocations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters allocations without apiKeyId", () => {
|
||||
const lsPool = {
|
||||
connectionId: "c1",
|
||||
allocations: [{ apiKeyId: "k1", percent: 100 }, { percent: 50 }],
|
||||
};
|
||||
const result = adaptLsPoolToApiSchema(lsPool);
|
||||
expect(result.allocations).toHaveLength(1);
|
||||
expect(result.allocations[0].apiKeyId).toBe("k1");
|
||||
});
|
||||
|
||||
it("clamps weight to 0-100", () => {
|
||||
const lsPool = {
|
||||
connectionId: "c1",
|
||||
allocations: [{ apiKeyId: "k1", percent: 150 }, { apiKeyId: "k2", percent: -10 }],
|
||||
};
|
||||
const result = adaptLsPoolToApiSchema(lsPool);
|
||||
expect(result.allocations[0].weight).toBe(100);
|
||||
expect(result.allocations[1].weight).toBe(0);
|
||||
});
|
||||
|
||||
it("uses provider as fallback name", () => {
|
||||
const lsPool = { connectionId: "conn_xyz", provider: "openai", allocations: [] };
|
||||
const result = adaptLsPoolToApiSchema(lsPool);
|
||||
expect(result.name).toBe("openai");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration tests for useLocalStoragePoolMigration hook ───────────────
|
||||
|
||||
const LS_KEY = "omniroute:quota-share:pools";
|
||||
|
||||
function HookWrapper({
|
||||
pools,
|
||||
mutate,
|
||||
}: {
|
||||
pools: object[];
|
||||
mutate: () => Promise<unknown>;
|
||||
}) {
|
||||
useLocalStoragePoolMigration({ pools: pools as never, mutate });
|
||||
return <div />;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
async function renderHook(props: Parameters<typeof HookWrapper>[0]) {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<HookWrapper {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useLocalStoragePoolMigration", { timeout: 10000 }, () => {
|
||||
const mockMutate = vi.fn().mockResolvedValue(undefined);
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
fetchSpy = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as unknown as Response)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
mockMutate.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root && container) act(() => root!.unmount());
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("does nothing when localStorage key is absent", async () => {
|
||||
await renderHook({ pools: [], mutate: mockMutate });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not migrate when DB already has pools (idempotency)", async () => {
|
||||
const lsPools = [{ connectionId: "c1", allocations: [] }];
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(lsPools));
|
||||
const existingPools = [{ id: "p1", connectionId: "c1", name: "Existing", allocations: [] }];
|
||||
await renderHook({ pools: existingPools, mutate: mockMutate });
|
||||
// fetch not called — pools already exist
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
// localStorage key preserved for user safety
|
||||
expect(localStorage.getItem(LS_KEY)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("migrates LS pools to API when DB is empty", async () => {
|
||||
const lsPools = [
|
||||
{ connectionId: "c1", accountLabel: "Acme", policy: "hard", allocations: [{ apiKeyId: "k1", percent: 100 }] },
|
||||
];
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(lsPools));
|
||||
await renderHook({ pools: [], mutate: mockMutate });
|
||||
// Small tick to let the Promise chain resolve
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/api/quota/pools",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(localStorage.getItem(LS_KEY)).toBeNull();
|
||||
expect(mockMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears invalid JSON from localStorage", async () => {
|
||||
localStorage.setItem(LS_KEY, "{invalid}");
|
||||
await renderHook({ pools: [], mutate: mockMutate });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(LS_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user