236 lines
6.4 KiB
TypeScript
236 lines
6.4 KiB
TypeScript
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<void>,
|
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
|
refreshCache: () => Promise<void>,
|
|
): Promise<BulkImportState> {
|
|
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();
|
|
});
|
|
});
|