Files
mission-control/tests/lib/queries.test.tsx
teernisse bcc55ec798 feat(bd-1fy): implement TanStack Query data fetching layer
Add query hooks for lore and bridge status with automatic invalidation
on lore-data-changed and sync-status events. Include mutations for
sync_now and reconcile operations that invalidate relevant queries.

- createQueryClient factory with appropriate defaults
- useLoreStatus hook with 30s staleTime and event invalidation
- useBridgeStatus hook with 30s staleTime and event invalidation
- useSyncNow mutation with query invalidation on success
- useReconcile mutation with query invalidation on success
- Centralized query keys for consistent invalidation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 11:00:30 -05:00

514 lines
12 KiB
TypeScript

/**
* 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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
// --- 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");
});
});
});