import type { ApiPath, BodyForPath, ParamsForPath, QueryParamsForPath, ResponseForPath, TemplateApiPaths } from '@shared/data/api/paths' import type { ConcreteApiPaths, PaginationResponse } from '@shared/data/api/types' import type { KeyedMutator } from 'swr' import { vi } from 'vitest' /** * Mock useDataApi hooks for testing * Provides comprehensive mocks for all data API hooks with realistic SWR-like behavior * Matches the actual interface from src/renderer/data/hooks/useDataApi.ts */ /** Mirror of ParamsOption from useDataApi so callers can pass `params` on template paths */ type ParamsOption = TPath extends TemplateApiPaths ? [ParamsForPath] extends [never] ? { params?: never } : { params: ParamsForPath } : { params?: never } type TriggerArgs = ParamsOption< TPath, TMethod > & { body?: BodyForPath query?: QueryParamsForPath } type RefreshOption = | ConcreteApiPaths[] | ((ctx: { args: TriggerArgs | undefined result: ResponseForPath }) => ConcreteApiPaths[]) /** * Create mock data based on API path */ function createMockDataForPath(path: string): any { if (path === '/files/entries/stats') { return { activeTotal: 0, trashTotal: 0, extCounts: [] } } if (path.includes('/topics')) { if (path.endsWith('/topics')) { return { topics: [ { id: 'topic1', name: 'Mock Topic 1', createdAt: '2024-01-01T00:00:00Z' }, { id: 'topic2', name: 'Mock Topic 2', createdAt: '2024-01-02T00:00:00Z' } ], total: 2 } } return { id: 'topic1', name: 'Mock Topic', messages: [], createdAt: '2024-01-01T00:00:00Z' } } if (path.includes('/messages')) { return { messages: [ { id: 'msg1', content: 'Mock message 1', role: 'user' }, { id: 'msg2', content: 'Mock message 2', role: 'assistant' } ], total: 2 } } if (path.includes('/paintings')) { return { items: [], total: 0, nextCursor: undefined } } return { id: 'mock_id', data: 'mock_data' } } /** * Mock useQuery hook * Matches actual signature: useQuery(path, options?) => { data, isLoading, isRefreshing, error, refetch, mutate } */ export const mockUseQuery = vi.fn( ( path: TPath, options?: ParamsOption & { query?: QueryParamsForPath enabled?: boolean swrOptions?: any } ): { data?: ResponseForPath isLoading: boolean isRefreshing: boolean error?: Error refetch: () => Promise mutate: KeyedMutator> } => { // Check if query is disabled if (options?.enabled === false) { return { data: undefined, isLoading: false, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator> } } const mockData = createMockDataForPath(path as string) return { data: mockData as ResponseForPath, isLoading: false, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(mockData), mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator> } } ) /** * Mock useMutation hook * Matches actual signature: useMutation(method, path, options?) => { trigger, isLoading, error } */ export const mockUseMutation = vi.fn( ( method: TMethod, _path: TPath, _options?: { onSuccess?: (data: ResponseForPath) => void onError?: (error: Error) => void refresh?: RefreshOption optimisticData?: ResponseForPath swrOptions?: any } ): { trigger: (data?: TriggerArgs) => Promise> isLoading: boolean error: Error | undefined } => { const mockTrigger = vi.fn(async (_data?: TriggerArgs) => { // Simulate different responses based on method switch (method) { case 'POST': return { id: 'new_item', created: true } as ResponseForPath case 'PUT': case 'PATCH': return { id: 'updated_item', updated: true } as ResponseForPath case 'DELETE': return { deleted: true } as ResponseForPath default: return { success: true } as ResponseForPath } }) return { trigger: mockTrigger, isLoading: false, error: undefined } } ) /** * Mock usePaginatedQuery hook * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, isLoading, isRefreshing, error, hasNext, hasPrev, prevPage, nextPage, refresh, reset } */ export const mockUsePaginatedQuery = vi.fn( ( path: TPath, _options?: ParamsOption & { query?: Omit, 'page' | 'limit'> limit?: number swrOptions?: any } ): ResponseForPath extends PaginationResponse ? { items: T[] total: number page: number isLoading: boolean isRefreshing: boolean error?: Error hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void refresh: () => Promise reset: () => void } : never => { const mockItems = path ? [ { id: 'item1', name: 'Mock Item 1' }, { id: 'item2', name: 'Mock Item 2' }, { id: 'item3', name: 'Mock Item 3' } ] : [] return { items: mockItems, total: mockItems.length, page: 1, isLoading: false, isRefreshing: false, error: undefined, hasNext: false, hasPrev: false, prevPage: vi.fn(), nextPage: vi.fn(), refresh: vi.fn().mockResolvedValue(undefined), reset: vi.fn() } as unknown as ResponseForPath extends PaginationResponse ? { items: T[] total: number page: number isLoading: boolean isRefreshing: boolean error?: Error hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void refresh: () => Promise reset: () => void } : never } ) /** * Mock useInfiniteQuery hook (cursor pagination). * Default returns an empty single page; tests override per-call via * mockUseInfiniteQuery.mockImplementation(...) or mockReturnValue(...). */ export const mockUseInfiniteQuery = vi.fn( ( _path: TPath, _options?: ParamsOption & { query?: Record limit?: number enabled?: boolean swrOptions?: any } ) => ({ pages: [] as Array<{ items: unknown[]; nextCursor?: string }>, isLoading: false, isRefreshing: false, error: undefined as Error | undefined, hasNext: false, loadNext: vi.fn(), refresh: vi.fn().mockResolvedValue(undefined), reset: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) }) ) /** * Mock useInfiniteFlatItems helper. * Mirrors production: flattens `pages[].items` honoring optional reverse flags. */ export const mockUseInfiniteFlatItems = vi.fn( (pages: Array<{ items: T[] }> | undefined, options?: { reversePages?: boolean; reverseItems?: boolean }): T[] => { if (!pages || pages.length === 0) return [] const ordered = options?.reversePages ? [...pages].reverse() : pages return ordered.flatMap((p) => (options?.reverseItems ? [...p.items].reverse() : p.items)) } ) /** * Mock useInvalidateCache hook * Matches actual signature: useInvalidateCache() => (keys?) => Promise */ export const mockUseInvalidateCache = vi.fn((): ((keys?: string | string[] | boolean) => Promise) => { const invalidate = vi.fn(async (_keys?: string | string[] | boolean) => { return Promise.resolve() }) return invalidate }) /** * Mock prefetch function * Matches actual signature: prefetch(path, options?) => Promise> */ export const mockPrefetch = vi.fn( async ( path: TPath, _options?: { query?: QueryParamsForPath } ): Promise> => { return createMockDataForPath(path) as ResponseForPath } ) // --------------------------------------------------------------------------- // useReadCache / useWriteCache mock state // // Both hooks talk to SWR's cache in production. In tests we replace the cache // with an in-memory Map keyed by a stable JSON serialization. The serializer // mirrors SWR's rule that empty `query` objects collapse to `[path]` so tests // can use either `seedCache('/x', v)` or `seedCache('/x', v, {})` and still // hit the same key as production code would. // --------------------------------------------------------------------------- const mockCacheStore = new Map() function buildMockCacheKey(path: string, query?: Record): string { const hasQuery = query !== undefined && Object.keys(query).length > 0 return hasQuery ? JSON.stringify([path, query]) : JSON.stringify([path]) } /** * Mock useReadCache hook * Matches actual signature: useReadCache() => (path, query?) => TResponse | undefined * * Returns a reader that reads from the shared mock cache store. Use * `MockUseDataApiUtils.seedCache()` to pre-populate values for tests. */ export const mockUseReadCache = vi.fn(() => { return vi.fn( ( path: ConcreteApiPaths | TemplateApiPaths, query?: Record ): TResponse | undefined => { return mockCacheStore.get(buildMockCacheKey(path as string, query)) as TResponse | undefined } ) }) /** * Mock useWriteCache hook * Matches actual signature: useWriteCache() => async (path, value, query?) => void * * Returns a writer that stores values in the shared mock cache store. The * writer's call history is preserved on the returned `vi.fn()`, so tests can * assert on it directly via `(writer as Mock).mock.calls`. */ export const mockUseWriteCache = vi.fn(() => { return vi.fn( async ( path: ConcreteApiPaths | TemplateApiPaths, value: TResponse, query?: Record ): Promise => { mockCacheStore.set(buildMockCacheKey(path as string, query), value) } ) }) /** * Export all mocks as a unified module */ export const MockUseDataApi = { useQuery: mockUseQuery, useMutation: mockUseMutation, useInfiniteQuery: mockUseInfiniteQuery, useInfiniteFlatItems: mockUseInfiniteFlatItems, usePaginatedQuery: mockUsePaginatedQuery, useInvalidateCache: mockUseInvalidateCache, useReadCache: mockUseReadCache, useWriteCache: mockUseWriteCache, prefetch: mockPrefetch } /** * Utility functions for testing */ export const MockUseDataApiUtils = { /** * Reset all hook mock call counts and implementations */ resetMocks: () => { mockUseQuery.mockClear() mockUseMutation.mockClear() mockUseInfiniteQuery.mockClear() mockUseInfiniteFlatItems.mockClear() mockUsePaginatedQuery.mockClear() mockUseInvalidateCache.mockClear() mockUseReadCache.mockClear() mockUseWriteCache.mockClear() mockPrefetch.mockClear() mockCacheStore.clear() }, /** * Pre-populate the mock cache store for useReadCache/useWriteCache tests. * * Key shape mirrors production: omit `query` (or pass `{}`) for `[path]`; * provide a non-empty query to key as `[path, query]`. */ seedCache: ( path: TPath, value: ResponseForPath, query?: Record ) => { mockCacheStore.set(buildMockCacheKey(path as string, query), value) }, /** * Read the current value stored at a cache key (e.g. to assert that * `useWriteCache` wrote the expected payload). */ getCachedValue: (path: ApiPath, query?: Record): TResponse | undefined => { return mockCacheStore.get(buildMockCacheKey(path as string, query)) as TResponse | undefined }, /** * Drop all seeded cache entries without clearing the hook mocks themselves. */ clearCache: () => { mockCacheStore.clear() }, /** * Set up useQuery to return specific data */ mockQueryData: (path: TPath, data: ResponseForPath) => { mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data, isLoading: false, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(data) } } // Default behavior for other paths const defaultData = createMockDataForPath(queryPath as string) return { data: defaultData, isLoading: false, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(defaultData) } }) }, /** * Set up useQuery to return loading state */ mockQueryLoading: (path: ApiPath) => { mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data: undefined, isLoading: true, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(undefined) } } const defaultData = createMockDataForPath(queryPath as string) return { data: defaultData, isLoading: false, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(defaultData) } }) }, /** * Set up useQuery to return an explicit result object for one path */ mockQueryResult: ( path: TPath, result: { data?: ResponseForPath isLoading?: boolean isRefreshing?: boolean error?: Error refetch?: () => Promise mutate?: KeyedMutator> } ) => { mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data: result.data, isLoading: result.isLoading ?? false, isRefreshing: result.isRefreshing ?? false, error: result.error, refetch: result.refetch ?? vi.fn().mockResolvedValue(result.data), mutate: result.mutate ?? (vi.fn().mockResolvedValue(result.data) as unknown as KeyedMutator>) } } const defaultData = createMockDataForPath(queryPath as string) return { data: defaultData, isLoading: false, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(defaultData) } }) }, /** * Set up useQuery to return error state */ mockQueryError: (path: ApiPath, error: Error) => { mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data: undefined, isLoading: false, isRefreshing: false, error, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(undefined) } } const defaultData = createMockDataForPath(queryPath as string) return { data: defaultData, isLoading: false, isRefreshing: false, error: undefined, refetch: vi.fn().mockResolvedValue(undefined), mutate: vi.fn().mockResolvedValue(defaultData) } }) }, /** * Set up useMutation to simulate success with specific result */ mockMutationSuccess: ( method: TMethod, path: TPath, result: ResponseForPath ) => { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { trigger: vi.fn().mockResolvedValue(result), isLoading: false, error: undefined } } // Default behavior return { trigger: vi.fn().mockResolvedValue({ success: true }), isLoading: false, error: undefined } }) }, /** * Set up useMutation to simulate error */ mockMutationError: ( method: TMethod, path: ApiPath, error: Error ) => { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { trigger: vi.fn().mockRejectedValue(error), isLoading: false, error: undefined } } // Default behavior return { trigger: vi.fn().mockResolvedValue({ success: true }), isLoading: false, error: undefined } }) }, /** * Set up useMutation to be in loading state */ mockMutationLoading: (method: TMethod, path: ApiPath) => { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves isLoading: true, error: undefined } } // Default behavior return { trigger: vi.fn().mockResolvedValue({ success: true }), isLoading: false, error: undefined } }) }, /** * Set up useMutation to use a caller-provided trigger function. * * Unlike `mockMutationSuccess`/`mockMutationError` (which create an internal * trigger), this variant lets the test supply its own `vi.fn()` so it can * assert on call arguments and control resolve/reject behavior. * * @example * const mockTrigger = vi.fn().mockResolvedValue({ id: '1', name: 'New' }) * MockUseDataApiUtils.mockMutationWithTrigger('POST', '/agents', mockTrigger) * // ...render hook, exercise mutation... * expect(mockTrigger).toHaveBeenCalledWith({ body: { name: 'New' } }) */ mockMutationWithTrigger: ( method: TMethod, path: TPath, trigger: ReturnType, options?: { isLoading?: boolean; error?: Error } ) => { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { trigger, isLoading: options?.isLoading ?? false, error: options?.error } } return { trigger: vi.fn().mockResolvedValue({ success: true }), isLoading: false, error: undefined } }) }, /** * Set up usePaginatedQuery to return specific items */ mockPaginatedData: ( path: TPath, items: any[], options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean } ) => { mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => { if (queryPath === path) { return { items, total: options?.total ?? items.length, page: options?.page ?? 1, isLoading: false, isRefreshing: false, error: undefined, hasNext: options?.hasNext ?? false, hasPrev: options?.hasPrev ?? false, prevPage: vi.fn(), nextPage: vi.fn(), refresh: vi.fn(), reset: vi.fn() } } // Default behavior return { items: [], total: 0, page: 1, isLoading: false, isRefreshing: false, error: undefined, hasNext: false, hasPrev: false, prevPage: vi.fn(), nextPage: vi.fn(), refresh: vi.fn(), reset: vi.fn() } }) } }