import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useBatchBadgeFetch, __resetBatchBadgeStoreForTests } from "../useBatchBadgeFetch"; import * as api from "../../api"; import type { BatchStatusResult } from "@fusion/core"; // Mock the API module vi.mock("../../api", () => ({ fetchBatchStatus: vi.fn(), })); const mockFetchBatchStatus = vi.mocked(api.fetchBatchStatus); describe("useBatchBadgeFetch", () => { beforeEach(() => { __resetBatchBadgeStoreForTests(); mockFetchBatchStatus.mockClear(); }); afterEach(() => { vi.clearAllMocks(); }); it("calls API with correct task IDs", async () => { const mockResult: BatchStatusResult = { "FN-001": { issueInfo: { url: "https://github.com/owner/repo/issues/1", number: 1, state: "open", title: "Test Issue", }, stale: false, }, }; mockFetchBatchStatus.mockResolvedValueOnce(mockResult); const { result } = renderHook(() => useBatchBadgeFetch()); await act(async () => { await result.current.fetchBatch(["FN-001"]); }); expect(mockFetchBatchStatus).toHaveBeenCalledWith(["FN-001"]); expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1); }); it("shares pending promise for concurrent calls with same IDs", async () => { const mockResult: BatchStatusResult = { "FN-001": { issueInfo: undefined, prInfo: undefined, stale: true }, }; // Create a delayed promise so we can verify deduplication let resolvePromise: (value: BatchStatusResult) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; }); mockFetchBatchStatus.mockReturnValueOnce(promise); const hook1 = renderHook(() => useBatchBadgeFetch()); const hook2 = renderHook(() => useBatchBadgeFetch()); // Start both fetches concurrently (but don't await yet) const fetchPromise1 = hook1.result.current.fetchBatch(["FN-001"]); const fetchPromise2 = hook2.result.current.fetchBatch(["FN-001"]); // Resolve the shared promise resolvePromise!(mockResult); // Now await both await act(async () => { await Promise.all([fetchPromise1, fetchPromise2]); }); // Should only make one API call due to promise deduplication expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1); }); it("uses cached data for calls within 5 seconds (no API call)", async () => { const mockResult: BatchStatusResult = { "FN-001": { issueInfo: { url: "https://github.com/owner/repo/issues/1", number: 1, state: "open", title: "Test Issue", }, stale: false, }, }; mockFetchBatchStatus.mockResolvedValueOnce(mockResult); const { result } = renderHook(() => useBatchBadgeFetch()); // First fetch await act(async () => { await result.current.fetchBatch(["FN-001"]); }); expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1); // Second fetch within 5 seconds - should use cache await act(async () => { await result.current.fetchBatch(["FN-001"]); }); // Should not make another API call expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1); }); it("makes new API call after 5 second cache expires", async () => { const mockResult: BatchStatusResult = { "FN-001": { issueInfo: undefined, prInfo: undefined, stale: true }, }; mockFetchBatchStatus.mockResolvedValue(mockResult); const { result } = renderHook(() => useBatchBadgeFetch()); // First fetch await act(async () => { await result.current.fetchBatch(["FN-001"]); }); expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1); // Wait for cache to expire (5 seconds + 1ms buffer) await new Promise((resolve) => setTimeout(resolve, 5100)); // Second fetch after cache expired await act(async () => { await result.current.fetchBatch(["FN-001"]); }); // Should make a new API call expect(mockFetchBatchStatus).toHaveBeenCalledTimes(2); }, 10000); it("retries 429 errors with exponential backoff", async () => { const rateLimitError = new Error("429 Rate limit exceeded"); const mockResult: BatchStatusResult = { "FN-001": { issueInfo: undefined, prInfo: undefined, stale: true }, }; // First calls fail with 429, eventually succeeds mockFetchBatchStatus .mockRejectedValueOnce(rateLimitError) .mockRejectedValueOnce(rateLimitError) .mockResolvedValueOnce(mockResult); const { result } = renderHook(() => useBatchBadgeFetch()); // Start the fetch await act(async () => { await result.current.fetchBatch(["FN-001"]); }); // Wait for retries (exponential backoff: 1s + 2s = 3s total) await new Promise((resolve) => setTimeout(resolve, 4000)); // Should have made multiple calls due to retries expect(mockFetchBatchStatus).toHaveBeenCalledTimes(3); }, 10000); it("does not retry non-429 errors", async () => { const otherError = new Error("Network error"); mockFetchBatchStatus.mockRejectedValueOnce(otherError); const { result } = renderHook(() => useBatchBadgeFetch()); await act(async () => { await result.current.fetchBatch(["FN-001"]); }); // Should only make one call (no retries for non-429 errors) expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1); }); it("returns undefined for uncached task IDs", () => { const { result } = renderHook(() => useBatchBadgeFetch()); const data = result.current.getBatchData("FN-UNKNOWN"); expect(data).toBeUndefined(); }); it("skips empty task ID arrays", async () => { const { result } = renderHook(() => useBatchBadgeFetch()); await act(async () => { await result.current.fetchBatch([]); }); expect(mockFetchBatchStatus).not.toHaveBeenCalled(); }); it("stores data and makes it available via getBatchData", async () => { const mockResult: BatchStatusResult = { "FN-001": { prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test PR", headBranch: "feature/test", baseBranch: "main", commentCount: 0, }, stale: false, }, }; mockFetchBatchStatus.mockResolvedValueOnce(mockResult); // Use a single hook instance for the entire test const { result } = renderHook(() => useBatchBadgeFetch()); // Fetch the data await act(async () => { await result.current.fetchBatch(["FN-001"]); }); // Verify data was stored - access result in the same act block let storedData; act(() => { storedData = result.current.getBatchData("FN-001"); }); expect(storedData).toBeDefined(); expect(storedData?.result.prInfo?.title).toBe("Test PR"); expect(storedData?.timestamp).toBeGreaterThan(0); }); it("module-level store shares data across hooks", async () => { const mockResult: BatchStatusResult = { "FN-001": { prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Shared PR", headBranch: "feature/shared", baseBranch: "main", commentCount: 0, }, stale: false, }, }; mockFetchBatchStatus.mockResolvedValueOnce(mockResult); // Create two hooks const hook1 = renderHook(() => useBatchBadgeFetch()); const hook2 = renderHook(() => useBatchBadgeFetch()); // Fetch from first hook await act(async () => { await hook1.result.current.fetchBatch(["FN-001"]); }); // Second hook should see the data via getBatchData let sharedData; act(() => { sharedData = hook2.result.current.getBatchData("FN-001"); }); expect(sharedData).toBeDefined(); expect(sharedData?.result.prInfo?.title).toBe("Shared PR"); }); });