/** * Tests for TanStack Query data fetching layer. * * Tests query hooks for lore data, bridge status, and mutations * for sync/reconcile operations. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode } from "react"; import { useLoreStatus, useBridgeStatus, useSyncNow, useReconcile, createQueryClient, } from "@/lib/queries"; import { invoke, resetMocks, setMockResponse, simulateEvent, } from "../mocks/tauri-api"; import type { LoreStatus, BridgeStatus, SyncResult } from "@/lib/types"; // --- Test Fixtures --- const mockLoreStatus: LoreStatus = { last_sync: "2026-02-26T10:00:00Z", is_healthy: true, message: "Synced successfully", summary: { open_issues: 5, authored_mrs: 3, reviewing_mrs: 2, }, }; const mockBridgeStatus: BridgeStatus = { mapping_count: 42, pending_count: 3, suspect_count: 1, last_sync: "2026-02-26T10:00:00Z", last_reconciliation: "2026-02-25T08:00:00Z", }; const mockSyncResult: SyncResult = { created: 5, skipped: 10, closed: 2, healed: 1, errors: [], }; // --- Test Wrapper --- function createTestQueryClient(): QueryClient { return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity, }, mutations: { retry: false, }, }, }); } function createWrapper(queryClient: QueryClient) { return function Wrapper({ children }: { children: ReactNode }) { return ( {children} ); }; } // --- QueryClient Setup Tests --- describe("createQueryClient", () => { it("creates a QueryClient with appropriate defaults", () => { const client = createQueryClient(); expect(client).toBeInstanceOf(QueryClient); const defaults = client.getDefaultOptions(); expect(defaults.queries?.retry).toBe(1); expect(defaults.queries?.refetchOnWindowFocus).toBe(true); }); }); // --- useLoreStatus Tests --- describe("useLoreStatus", () => { let queryClient: QueryClient; beforeEach(() => { queryClient = createTestQueryClient(); resetMocks(); }); afterEach(() => { queryClient.clear(); }); it("fetches lore status successfully", async () => { setMockResponse("get_lore_status", mockLoreStatus); const { result } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data).toEqual(mockLoreStatus); expect(invoke).toHaveBeenCalledWith("get_lore_status"); }); it("shows loading state initially", () => { const { result } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); expect(result.current.isLoading).toBe(true); expect(result.current.data).toBeUndefined(); }); it("handles lore unavailable error", async () => { const mockError = { code: "LORE_UNAVAILABLE", message: "lore CLI not found", recoverable: true, }; invoke.mockRejectedValueOnce(mockError); const { result } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isError).toBe(true); }); expect(result.current.error).toEqual(mockError); }); it("invalidates on lore-data-changed event", async () => { setMockResponse("get_lore_status", mockLoreStatus); const { result } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); // Initial fetch expect(invoke).toHaveBeenCalledTimes(1); // Simulate event act(() => { simulateEvent("lore-data-changed", undefined); }); // Should refetch await waitFor(() => { expect(invoke).toHaveBeenCalledTimes(2); }); }); it("uses staleTime of 30 seconds", async () => { setMockResponse("get_lore_status", mockLoreStatus); const { result: result1 } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result1.current.isSuccess).toBe(true); }); // Second hook should use cached data, not refetch const { result: result2 } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); // Data should be immediately available (from cache) expect(result2.current.data).toEqual(mockLoreStatus); // Still only one invoke call expect(invoke).toHaveBeenCalledTimes(1); }); }); // --- useBridgeStatus Tests --- describe("useBridgeStatus", () => { let queryClient: QueryClient; beforeEach(() => { queryClient = createTestQueryClient(); resetMocks(); }); afterEach(() => { queryClient.clear(); }); it("fetches bridge status successfully", async () => { setMockResponse("get_bridge_status", mockBridgeStatus); const { result } = renderHook(() => useBridgeStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data).toEqual(mockBridgeStatus); expect(invoke).toHaveBeenCalledWith("get_bridge_status"); }); it("shows loading state initially", () => { const { result } = renderHook(() => useBridgeStatus(), { wrapper: createWrapper(queryClient), }); expect(result.current.isLoading).toBe(true); }); it("handles bridge error", async () => { const mockError = { code: "BRIDGE_LOCKED", message: "Bridge is locked by another process", recoverable: true, }; invoke.mockRejectedValueOnce(mockError); const { result } = renderHook(() => useBridgeStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isError).toBe(true); }); expect(result.current.error).toEqual(mockError); }); it("invalidates on lore-data-changed event", async () => { setMockResponse("get_bridge_status", mockBridgeStatus); const { result } = renderHook(() => useBridgeStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(invoke).toHaveBeenCalledTimes(1); // Simulate event act(() => { simulateEvent("lore-data-changed", undefined); }); await waitFor(() => { expect(invoke).toHaveBeenCalledTimes(2); }); }); }); // --- useSyncNow Mutation Tests --- describe("useSyncNow", () => { let queryClient: QueryClient; beforeEach(() => { queryClient = createTestQueryClient(); resetMocks(); }); afterEach(() => { queryClient.clear(); }); it("triggers sync and returns result", async () => { setMockResponse("sync_now", mockSyncResult); const { result } = renderHook(() => useSyncNow(), { wrapper: createWrapper(queryClient), }); expect(result.current.isPending).toBe(false); act(() => { result.current.mutate(); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(invoke).toHaveBeenCalledWith("sync_now"); expect(result.current.data).toEqual(mockSyncResult); }); it("invalidates lore and bridge queries on success", async () => { setMockResponse("sync_now", mockSyncResult); setMockResponse("get_lore_status", mockLoreStatus); setMockResponse("get_bridge_status", mockBridgeStatus); // First, set up some cached data const { result: loreResult } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(loreResult.current.isSuccess).toBe(true); }); const { result: bridgeResult } = renderHook(() => useBridgeStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(bridgeResult.current.isSuccess).toBe(true); }); // Clear call counts after initial fetches invoke.mockClear(); // Now trigger sync const { result: syncResult } = renderHook(() => useSyncNow(), { wrapper: createWrapper(queryClient), }); await act(async () => { await syncResult.current.mutateAsync(); }); // Should have called sync_now plus refetched both status queries await waitFor(() => { expect(invoke).toHaveBeenCalledWith("sync_now"); expect(invoke).toHaveBeenCalledWith("get_lore_status"); expect(invoke).toHaveBeenCalledWith("get_bridge_status"); }); }); it("handles sync failure", async () => { const mockError = { code: "BRIDGE_SYNC_FAILED", message: "Sync failed", recoverable: true, }; invoke.mockRejectedValueOnce(mockError); const { result } = renderHook(() => useSyncNow(), { wrapper: createWrapper(queryClient), }); act(() => { result.current.mutate(); }); await waitFor(() => { expect(result.current.isError).toBe(true); }); expect(result.current.error).toEqual(mockError); }); }); // --- useReconcile Mutation Tests --- describe("useReconcile", () => { let queryClient: QueryClient; beforeEach(() => { queryClient = createTestQueryClient(); resetMocks(); }); afterEach(() => { queryClient.clear(); }); it("triggers reconciliation and returns result", async () => { const reconcileResult: SyncResult = { ...mockSyncResult, closed: 5, // More closures in full reconcile healed: 3, }; setMockResponse("reconcile", reconcileResult); const { result } = renderHook(() => useReconcile(), { wrapper: createWrapper(queryClient), }); act(() => { result.current.mutate(); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(invoke).toHaveBeenCalledWith("reconcile"); expect(result.current.data).toEqual(reconcileResult); }); it("invalidates queries on success", async () => { setMockResponse("reconcile", mockSyncResult); setMockResponse("get_lore_status", mockLoreStatus); setMockResponse("get_bridge_status", mockBridgeStatus); // Set up cached queries const { result: loreResult } = renderHook(() => useLoreStatus(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(loreResult.current.isSuccess).toBe(true); }); invoke.mockClear(); // Trigger reconcile const { result } = renderHook(() => useReconcile(), { wrapper: createWrapper(queryClient), }); await act(async () => { await result.current.mutateAsync(); }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith("reconcile"); expect(invoke).toHaveBeenCalledWith("get_lore_status"); }); }); it("handles reconcile failure", async () => { const mockError = { code: "LORE_FETCH_FAILED", message: "Failed to fetch from lore", recoverable: true, }; invoke.mockRejectedValueOnce(mockError); const { result } = renderHook(() => useReconcile(), { wrapper: createWrapper(queryClient), }); act(() => { result.current.mutate(); }); await waitFor(() => { expect(result.current.isError).toBe(true); }); expect(result.current.error).toEqual(mockError); }); }); // --- useLoreItems Tests --- describe("useLoreItems", () => { let queryClient: QueryClient; beforeEach(() => { queryClient = createTestQueryClient(); resetMocks(); }); afterEach(() => { queryClient.clear(); }); it("fetches and transforms lore items successfully", async () => { const mockItemsResponse = { items: [ { id: "mr_review:group::repo:200", title: "Review this MR", item_type: "mr_review", project: "group/repo", url: "https://gitlab.com/group/repo/-/merge_requests/200", iid: 200, updated_at: "2026-02-26T10:00:00Z", requested_by: "alice", }, { id: "issue:group::repo:42", title: "Fix the bug", item_type: "issue", project: "group/repo", url: "https://gitlab.com/group/repo/-/issues/42", iid: 42, updated_at: "2026-02-26T09:00:00Z", requested_by: null, }, ], success: true, error: null, }; setMockResponse("get_lore_items", mockItemsResponse); // Import dynamically to avoid circular dependency in test setup const { useLoreItems } = await import("@/lib/queries"); const { result } = renderHook(() => useLoreItems(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data).toBeDefined(); expect(result.current.data?.length).toBe(2); // Verify transformation to FocusItem format const firstItem = result.current.data?.[0]; expect(firstItem?.id).toBe("mr_review:group::repo:200"); expect(firstItem?.title).toBe("Review this MR"); expect(firstItem?.type).toBe("mr_review"); expect(firstItem?.requestedBy).toBe("alice"); }); it("returns empty array when lore fetch fails", async () => { const mockFailedResponse = { items: [], success: false, error: "lore CLI not found", }; setMockResponse("get_lore_items", mockFailedResponse); const { useLoreItems } = await import("@/lib/queries"); const { result } = renderHook(() => useLoreItems(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data).toEqual([]); }); it("invalidates on lore-data-changed event", async () => { const mockItemsResponse = { items: [], success: true, error: null, }; setMockResponse("get_lore_items", mockItemsResponse); const { useLoreItems } = await import("@/lib/queries"); const { result } = renderHook(() => useLoreItems(), { wrapper: createWrapper(queryClient), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(invoke).toHaveBeenCalledTimes(1); // Simulate event act(() => { simulateEvent("lore-data-changed", undefined); }); await waitFor(() => { expect(invoke).toHaveBeenCalledTimes(2); }); }); });