import { describe, expect, it, vi } from "vitest"; import * as indexAdapter from "../adapters/bestiary-index-adapter.js"; // We test the bulk import logic by extracting and exercising the async flow. // Since useBulkImport is a thin React wrapper around async logic, // we test the core behavior via a direct simulation. vi.mock("../adapters/bestiary-index-adapter.js", async () => { const actual = await vi.importActual< typeof import("../adapters/bestiary-index-adapter.js") >("../adapters/bestiary-index-adapter.js"); return { ...actual, getAllSourceCodes: vi.fn(), }; }); const mockGetAllSourceCodes = vi.mocked(indexAdapter.getAllSourceCodes); interface BulkImportState { status: "idle" | "loading" | "complete" | "partial-failure"; total: number; completed: number; failed: number; } /** Simulate the core bulk import logic extracted from the hook */ async function runBulkImport( baseUrl: string, fetchAndCacheSource: (sourceCode: string, url: string) => Promise, isSourceCached: (sourceCode: string) => Promise, refreshCache: () => Promise, ): Promise { const allCodes = indexAdapter.getAllSourceCodes(); const total = allCodes.length; const cacheChecks = await Promise.all( allCodes.map(async (code) => ({ code, cached: await isSourceCached(code), })), ); const alreadyCached = cacheChecks.filter((c) => c.cached).length; const uncached = cacheChecks.filter((c) => !c.cached); if (uncached.length === 0) { return { status: "complete", total, completed: total, failed: 0 }; } let completed = alreadyCached; let failed = 0; await Promise.allSettled( uncached.map(async ({ code }) => { const url = indexAdapter.getDefaultFetchUrl(code, baseUrl); try { await fetchAndCacheSource(code, url); completed++; } catch { failed++; } }), ); await refreshCache(); return { status: failed > 0 ? "partial-failure" : "complete", total, completed, failed, }; } describe("bulk import logic", () => { it("skips already-cached sources and counts them into completed", async () => { mockGetAllSourceCodes.mockReturnValue(["SRC1", "SRC2", "SRC3"]); const fetchAndCache = vi.fn().mockResolvedValue(undefined); const isSourceCached = vi .fn() .mockImplementation((code: string) => Promise.resolve(code === "SRC1" || code === "SRC3"), ); const refreshCache = vi.fn().mockResolvedValue(undefined); const result = await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(fetchAndCache).toHaveBeenCalledTimes(1); expect(fetchAndCache).toHaveBeenCalledWith( "SRC2", "https://example.com/bestiary-src2.json", ); expect(result.completed).toBe(3); expect(result.status).toBe("complete"); }); it("increments completed on successful fetch", async () => { mockGetAllSourceCodes.mockReturnValue(["SRC1"]); const fetchAndCache = vi.fn().mockResolvedValue(undefined); const isSourceCached = vi.fn().mockResolvedValue(false); const refreshCache = vi.fn().mockResolvedValue(undefined); const result = await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(result.completed).toBe(1); expect(result.failed).toBe(0); expect(result.status).toBe("complete"); }); it("increments failed on rejected fetch", async () => { mockGetAllSourceCodes.mockReturnValue(["SRC1"]); const fetchAndCache = vi.fn().mockRejectedValue(new Error("fail")); const isSourceCached = vi.fn().mockResolvedValue(false); const refreshCache = vi.fn().mockResolvedValue(undefined); const result = await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(result.failed).toBe(1); expect(result.completed).toBe(0); expect(result.status).toBe("partial-failure"); }); it("transitions to complete when all succeed", async () => { mockGetAllSourceCodes.mockReturnValue(["A", "B"]); const fetchAndCache = vi.fn().mockResolvedValue(undefined); const isSourceCached = vi.fn().mockResolvedValue(false); const refreshCache = vi.fn().mockResolvedValue(undefined); const result = await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(result.status).toBe("complete"); expect(result.completed).toBe(2); expect(result.failed).toBe(0); }); it("transitions to partial-failure when any fetch fails", async () => { mockGetAllSourceCodes.mockReturnValue(["A", "B"]); const fetchAndCache = vi .fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("fail")); const isSourceCached = vi.fn().mockResolvedValue(false); const refreshCache = vi.fn().mockResolvedValue(undefined); const result = await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(result.status).toBe("partial-failure"); expect(result.failed).toBe(1); }); it("immediately transitions to complete when all sources are cached", async () => { mockGetAllSourceCodes.mockReturnValue(["A", "B", "C"]); const fetchAndCache = vi.fn(); const isSourceCached = vi.fn().mockResolvedValue(true); const refreshCache = vi.fn().mockResolvedValue(undefined); const result = await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(result.status).toBe("complete"); expect(result.total).toBe(3); expect(result.completed).toBe(3); expect(fetchAndCache).not.toHaveBeenCalled(); }); it("calls refreshCache exactly once when all settle", async () => { mockGetAllSourceCodes.mockReturnValue(["A", "B"]); const fetchAndCache = vi.fn().mockResolvedValue(undefined); const isSourceCached = vi.fn().mockResolvedValue(false); const refreshCache = vi.fn().mockResolvedValue(undefined); await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(refreshCache).toHaveBeenCalledTimes(1); }); it("does not call refreshCache when all sources are already cached", async () => { mockGetAllSourceCodes.mockReturnValue(["A"]); const fetchAndCache = vi.fn(); const isSourceCached = vi.fn().mockResolvedValue(true); const refreshCache = vi.fn().mockResolvedValue(undefined); await runBulkImport( "https://example.com/", fetchAndCache, isSourceCached, refreshCache, ); expect(refreshCache).not.toHaveBeenCalled(); }); });