diff --git a/tests/unit/sidebar-costs-quota-plans.test.ts b/tests/unit/sidebar-costs-quota-plans.test.ts new file mode 100644 index 0000000000..7ac5609c4d --- /dev/null +++ b/tests/unit/sidebar-costs-quota-plans.test.ts @@ -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"); +}); diff --git a/tests/unit/ui/allocation-table.test.tsx b/tests/unit/ui/allocation-table.test.tsx new file mode 100644 index 0000000000..c03657eb6e --- /dev/null +++ b/tests/unit/ui/allocation-table.test.tsx @@ -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 = { key_1: "KeyOne", key_2: "KeyTwo" }; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function render(props: Parameters[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(); + }); +} + +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"); + }); +}); diff --git a/tests/unit/ui/burn-rate-chart.test.tsx b/tests/unit/ui/burn-rate-chart.test.tsx new file mode 100644 index 0000000000..2148fca4be --- /dev/null +++ b/tests/unit/ui/burn-rate-chart.test.tsx @@ -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 | null = null; + +async function render(props: Parameters[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(); + }); +} + +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"); + }); +}); diff --git a/tests/unit/ui/pool-card.test.tsx b/tests/unit/ui/pool-card.test.tsx new file mode 100644 index 0000000000..fc0bdd8d6b --- /dev/null +++ b/tests/unit/ui/pool-card.test.tsx @@ -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 }) =>
{children}
, +})); + +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: ({ providerId }: { providerId: string }) => ( + + ), +})); + +// Stub sub-components +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/DimensionBar", + () => ({ default: ({ dimension }: { dimension: { unit: string } }) =>
{dimension.unit}
}) +); +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable", + () => ({ default: () =>
}) +); +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart", + () => ({ default: () =>
}) +); + +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 | 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( + + ); + }); +} + +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( + + ); + }); + const editBtn = document.querySelector("button[title='editAllocations']") as HTMLButtonElement; + expect(editBtn).not.toBeNull(); + await act(async () => editBtn.click()); + expect(onEdit).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/ui/provider-plan-config.test.tsx b/tests/unit/ui/provider-plan-config.test.tsx new file mode 100644 index 0000000000..0351a92a54 --- /dev/null +++ b/tests/unit/ui/provider-plan-config.test.tsx @@ -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; + }) => ( + + ), +})); + +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: () => , +})); + +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 | 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(); + }); + // 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"); + }); +}); diff --git a/tests/unit/ui/quota-share-page.test.tsx b/tests/unit/ui/quota-share-page.test.tsx new file mode 100644 index 0000000000..7340d6d63d --- /dev/null +++ b/tests/unit/ui/quota-share-page.test.tsx @@ -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 }) => ( + + ), + Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => + isOpen ?
{children}
: null, +})); +vi.mock("@/shared/components/Card", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: () => , +})); + +// ── 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 | 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(); + }); +} + +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"); + }); +}); diff --git a/tests/unit/ui/use-local-storage-pool-migration.test.tsx b/tests/unit/ui/use-local-storage-pool-migration.test.tsx new file mode 100644 index 0000000000..8bac55841d --- /dev/null +++ b/tests/unit/ui/use-local-storage-pool-migration.test.tsx @@ -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; +}) { + useLocalStoragePoolMigration({ pools: pools as never, mutate }); + return
; +} + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function renderHook(props: Parameters[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(); + }); +} + +describe("useLocalStoragePoolMigration", { timeout: 10000 }, () => { + const mockMutate = vi.fn().mockResolvedValue(undefined); + let fetchSpy: ReturnType; + + 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(); + }); +});