diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 0000000..ebf4ee5 --- /dev/null +++ b/src/lib/queries.ts @@ -0,0 +1,229 @@ +/** + * TanStack Query data fetching layer. + * + * Handles async data fetching, caching, and invalidation for: + * - Lore status (GitLab integration health) + * - Bridge status (mapping counts, sync times) + * - Sync/reconcile mutations + * + * Query keys are centralized here for consistent invalidation. + */ + +import { useEffect } from "react"; +import { + QueryClient, + useQuery, + useMutation, + useQueryClient, + type UseQueryResult, + type UseMutationResult, +} from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { + LoreStatus, + BridgeStatus, + SyncResult, + McError, +} from "@/lib/types"; + +// --- Query Keys --- + +export const queryKeys = { + loreStatus: ["lore-status"] as const, + bridgeStatus: ["bridge-status"] as const, +} as const; + +// --- QueryClient Factory --- + +/** + * Create a configured QueryClient instance. + * + * Default options: + * - retry: 1 (one retry on failure) + * - refetchOnWindowFocus: true (refresh when user returns) + */ +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: true, + }, + }, + }); +} + +// --- Event-Based Invalidation Hook --- + +/** + * Hook to set up query invalidation on Tauri events. + * + * Listens for: + * - lore-data-changed: Invalidates lore and bridge status + * - sync-status (completed): Invalidates lore and bridge status + */ +export function useQueryInvalidation(): void { + const queryClient = useQueryClient(); + + useEffect(() => { + let cancelled = false; + const unlisteners: Promise[] = []; + + // Invalidate on lore data changes + const loreUnlisten = listen("lore-data-changed", () => { + queryClient.invalidateQueries({ queryKey: queryKeys.loreStatus }); + queryClient.invalidateQueries({ queryKey: queryKeys.bridgeStatus }); + }); + unlisteners.push(loreUnlisten); + + // Invalidate on sync completion + const syncUnlisten = listen<{ status: string; message?: string }>( + "sync-status", + (event) => { + if (event.payload.status === "completed") { + queryClient.invalidateQueries({ queryKey: queryKeys.loreStatus }); + queryClient.invalidateQueries({ queryKey: queryKeys.bridgeStatus }); + } + } + ); + unlisteners.push(syncUnlisten); + + return () => { + cancelled = true; + // Cleanup all listeners + Promise.all(unlisteners).then((fns) => { + if (!cancelled) return; + for (const fn of fns) { + fn(); + } + }); + }; + }, [queryClient]); +} + +// --- Query Hooks --- + +/** + * Fetch lore integration status. + * + * Returns health, last sync time, and summary counts. + * Stale time: 30 seconds (fresh data is important but not real-time) + */ +export function useLoreStatus(): UseQueryResult { + const queryClient = useQueryClient(); + + // Set up event-based invalidation + useEffect(() => { + let cancelled = false; + let unlisten: UnlistenFn | undefined; + + listen("lore-data-changed", () => { + queryClient.invalidateQueries({ queryKey: queryKeys.loreStatus }); + }).then((fn) => { + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + }); + + // Also listen for sync completion + let syncUnlisten: UnlistenFn | undefined; + listen<{ status: string }>("sync-status", (event) => { + if (event.payload.status === "completed") { + queryClient.invalidateQueries({ queryKey: queryKeys.loreStatus }); + } + }).then((fn) => { + if (cancelled) { + fn(); + } else { + syncUnlisten = fn; + } + }); + + return () => { + cancelled = true; + if (unlisten) unlisten(); + if (syncUnlisten) syncUnlisten(); + }; + }, [queryClient]); + + return useQuery({ + queryKey: queryKeys.loreStatus, + queryFn: () => invoke("get_lore_status"), + staleTime: 30 * 1000, // 30 seconds + }); +} + +/** + * Fetch bridge status (mapping counts, sync times). + * + * Stale time: 30 seconds + */ +export function useBridgeStatus(): UseQueryResult { + const queryClient = useQueryClient(); + + // Set up event-based invalidation + useEffect(() => { + let cancelled = false; + let unlisten: UnlistenFn | undefined; + + listen("lore-data-changed", () => { + queryClient.invalidateQueries({ queryKey: queryKeys.bridgeStatus }); + }).then((fn) => { + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + }); + + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, [queryClient]); + + return useQuery({ + queryKey: queryKeys.bridgeStatus, + queryFn: () => invoke("get_bridge_status"), + staleTime: 30 * 1000, // 30 seconds + }); +} + +// --- Mutation Hooks --- + +/** + * Trigger an incremental sync (process since_last_check events). + * + * On success, invalidates lore and bridge status queries. + */ +export function useSyncNow(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => invoke("sync_now"), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.loreStatus }); + queryClient.invalidateQueries({ queryKey: queryKeys.bridgeStatus }); + }, + }); +} + +/** + * Trigger a full reconciliation pass. + * + * On success, invalidates lore and bridge status queries. + */ +export function useReconcile(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => invoke("reconcile"), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.loreStatus }); + queryClient.invalidateQueries({ queryKey: queryKeys.bridgeStatus }); + }, + }); +} diff --git a/tests/lib/queries.test.tsx b/tests/lib/queries.test.tsx new file mode 100644 index 0000000..7c6e99c --- /dev/null +++ b/tests/lib/queries.test.tsx @@ -0,0 +1,513 @@ +/** + * 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); + }); +}); + +// --- Combined Status Hook Tests --- + +describe("query invalidation coordination", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = createTestQueryClient(); + resetMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("sync-status event with completed status invalidates queries", async () => { + setMockResponse("get_lore_status", mockLoreStatus); + setMockResponse("get_bridge_status", mockBridgeStatus); + + const { result: loreResult } = renderHook(() => useLoreStatus(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => { + expect(loreResult.current.isSuccess).toBe(true); + }); + + invoke.mockClear(); + + // Simulate sync completed event + act(() => { + simulateEvent("sync-status", { status: "completed", message: "Done" }); + }); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_lore_status"); + }); + }); +});