Files
CherryHQ-cherry-studio/tests/__mocks__/renderer/useDataApi.ts
fullex 35256975e3 refactor(shared-data-api): drop the api barrel and flatten module names per naming §6.4
The api/index.ts barrel re-exported only infra types yet required domain DTOs
to deep-import schemas/<domain>, colliding with naming §6.4 rule 2
(enforced-or-no-barrel) and accounting for the bulk of barrel/closed lint
violations. Because schema files carry zod runtime values, any aggregating
barrel drags all 24 domains in on a single import — exactly what the barrel
reform removes.

- Remove api/index.ts; api/ is now an open namespace.
- Flatten infra file names: apiTypes/apiErrors/apiPaths -> types/errors/paths;
  schemas/index.ts -> schemas/apiSchemas.ts.
- Rewrite all consumers: 71 deep-import path renames + 106 barrel imports split
  to their owning module (types/paths/errors), including two dynamic imports and
  the tests/__mocks__ fixtures.
- Sync data docs and codify the schemas/ `_xxx.ts` = cross-resource construct
  convention in naming-conventions §3.2.
2026-07-04 21:51:21 -07:00

702 lines
21 KiB
TypeScript

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 string, TMethod extends string> = TPath extends TemplateApiPaths
? [ParamsForPath<TPath, TMethod>] extends [never]
? { params?: never }
: { params: ParamsForPath<TPath, TMethod> }
: { params?: never }
type TriggerArgs<TPath extends ApiPath, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'> = ParamsOption<
TPath,
TMethod
> & {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath, TMethod>
}
type RefreshOption<TPath extends ApiPath, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'> =
| ConcreteApiPaths[]
| ((ctx: {
args: TriggerArgs<TPath, TMethod> | undefined
result: ResponseForPath<TPath, TMethod>
}) => 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(
<TPath extends ApiPath>(
path: TPath,
options?: ParamsOption<TPath, 'GET'> & {
query?: QueryParamsForPath<TPath, 'GET'>
enabled?: boolean
swrOptions?: any
}
): {
data?: ResponseForPath<TPath, 'GET'>
isLoading: boolean
isRefreshing: boolean
error?: Error
refetch: () => Promise<unknown>
mutate: KeyedMutator<ResponseForPath<TPath, 'GET'>>
} => {
// 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<ResponseForPath<TPath, 'GET'>>
}
}
const mockData = createMockDataForPath(path as string)
return {
data: mockData as ResponseForPath<TPath, 'GET'>,
isLoading: false,
isRefreshing: false,
error: undefined,
refetch: vi.fn().mockResolvedValue(mockData),
mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator<ResponseForPath<TPath, 'GET'>>
}
}
)
/**
* Mock useMutation hook
* Matches actual signature: useMutation(method, path, options?) => { trigger, isLoading, error }
*/
export const mockUseMutation = vi.fn(
<TPath extends ApiPath, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod,
_path: TPath,
_options?: {
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
onError?: (error: Error) => void
refresh?: RefreshOption<TPath, TMethod>
optimisticData?: ResponseForPath<TPath, TMethod>
swrOptions?: any
}
): {
trigger: (data?: TriggerArgs<TPath, TMethod>) => Promise<ResponseForPath<TPath, TMethod>>
isLoading: boolean
error: Error | undefined
} => {
const mockTrigger = vi.fn(async (_data?: TriggerArgs<TPath, TMethod>) => {
// Simulate different responses based on method
switch (method) {
case 'POST':
return { id: 'new_item', created: true } as ResponseForPath<TPath, TMethod>
case 'PUT':
case 'PATCH':
return { id: 'updated_item', updated: true } as ResponseForPath<TPath, TMethod>
case 'DELETE':
return { deleted: true } as ResponseForPath<TPath, TMethod>
default:
return { success: true } as ResponseForPath<TPath, TMethod>
}
})
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(
<TPath extends ApiPath>(
path: TPath,
_options?: ParamsOption<TPath, 'GET'> & {
query?: Omit<QueryParamsForPath<TPath, 'GET'>, 'page' | 'limit'>
limit?: number
swrOptions?: any
}
): ResponseForPath<TPath, 'GET'> extends PaginationResponse<infer T>
? {
items: T[]
total: number
page: number
isLoading: boolean
isRefreshing: boolean
error?: Error
hasNext: boolean
hasPrev: boolean
prevPage: () => void
nextPage: () => void
refresh: () => Promise<unknown>
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<TPath, 'GET'> extends PaginationResponse<infer T>
? {
items: T[]
total: number
page: number
isLoading: boolean
isRefreshing: boolean
error?: Error
hasNext: boolean
hasPrev: boolean
prevPage: () => void
nextPage: () => void
refresh: () => Promise<unknown>
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(
<TPath extends ApiPath>(
_path: TPath,
_options?: ParamsOption<TPath, 'GET'> & {
query?: Record<string, unknown>
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(
<T>(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<any>
*/
export const mockUseInvalidateCache = vi.fn((): ((keys?: string | string[] | boolean) => Promise<any>) => {
const invalidate = vi.fn(async (_keys?: string | string[] | boolean) => {
return Promise.resolve()
})
return invalidate
})
/**
* Mock prefetch function
* Matches actual signature: prefetch(path, options?) => Promise<ResponseForPath<TPath, 'GET'>>
*/
export const mockPrefetch = vi.fn(
async <TPath extends ConcreteApiPaths>(
path: TPath,
_options?: {
query?: QueryParamsForPath<TPath, 'GET'>
}
): Promise<ResponseForPath<TPath, 'GET'>> => {
return createMockDataForPath(path) as ResponseForPath<TPath, 'GET'>
}
)
// ---------------------------------------------------------------------------
// 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<string, unknown>()
function buildMockCacheKey(path: string, query?: Record<string, unknown>): 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(
<TResponse = unknown>(
path: ConcreteApiPaths | TemplateApiPaths,
query?: Record<string, unknown>
): 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 <TResponse = unknown>(
path: ConcreteApiPaths | TemplateApiPaths,
value: TResponse,
query?: Record<string, unknown>
): Promise<void> => {
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: <TPath extends ApiPath>(
path: TPath,
value: ResponseForPath<TPath, 'GET'>,
query?: Record<string, unknown>
) => {
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: <TResponse = unknown>(path: ApiPath, query?: Record<string, unknown>): 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: <TPath extends ApiPath>(path: TPath, data: ResponseForPath<TPath, 'GET'>) => {
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: <TPath extends ApiPath>(
path: TPath,
result: {
data?: ResponseForPath<TPath, 'GET'>
isLoading?: boolean
isRefreshing?: boolean
error?: Error
refetch?: () => Promise<unknown>
mutate?: KeyedMutator<ResponseForPath<TPath, 'GET'>>
}
) => {
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<ResponseForPath<TPath, 'GET'>>)
}
}
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: <TPath extends ApiPath, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod,
path: TPath,
result: ResponseForPath<TPath, TMethod>
) => {
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: <TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
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: <TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(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: <TPath extends ApiPath, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod,
path: TPath,
trigger: ReturnType<typeof vi.fn>,
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: <TPath extends ApiPath>(
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()
}
})
}
}