From 773e9eac32e26eeaf247ceb4656fd217a367e6c3 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 10:24:35 +0800 Subject: [PATCH] feat: enhance mock services for improved testing capabilities - Updated `CacheService` mock to include TTL support and type-safe memory and shared cache methods, enhancing the accuracy of cache behavior during tests. - Refactored `DataApiService` mock to implement realistic HTTP methods and subscription handling, including retry configuration and request management, improving the fidelity of API interactions in tests. - Enhanced `useDataApi` mocks to align with actual hook signatures, providing a more accurate simulation of data fetching and mutation behaviors, including loading and error states. - Introduced utility functions for managing mock state and triggering subscription callbacks, streamlining the testing process for components relying on these services. --- tests/__mocks__/renderer/CacheService.ts | 584 ++++++++++++++------- tests/__mocks__/renderer/DataApiService.ts | 307 ++++++++--- tests/__mocks__/renderer/useDataApi.ts | 442 +++++++++------- 3 files changed, 895 insertions(+), 438 deletions(-) diff --git a/tests/__mocks__/renderer/CacheService.ts b/tests/__mocks__/renderer/CacheService.ts index fbbdc10a25..a66d8ddba6 100644 --- a/tests/__mocks__/renderer/CacheService.ts +++ b/tests/__mocks__/renderer/CacheService.ts @@ -2,15 +2,18 @@ import type { RendererPersistCacheKey, RendererPersistCacheSchema, UseCacheKey, - SharedCacheKey + UseCacheSchema, + SharedCacheKey, + SharedCacheSchema } from '@shared/data/cache/cacheSchemas' import { DefaultRendererPersistCache, DefaultUseCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas' -import type { CacheSubscriber } from '@shared/data/cache/cacheTypes' +import type { CacheEntry, CacheSubscriber } from '@shared/data/cache/cacheTypes' import { vi } from 'vitest' /** * Mock CacheService for testing * Provides a comprehensive mock of the three-layer cache system + * Matches the actual CacheService interface from src/renderer/src/data/CacheService.ts */ /** @@ -18,19 +21,34 @@ import { vi } from 'vitest' */ export const createMockCacheService = ( options: { - initialMemoryCache?: Map - initialSharedCache?: Map + initialMemoryCache?: Map + initialSharedCache?: Map initialPersistCache?: Map } = {} ) => { - // Mock cache storage - const memoryCache = new Map(options.initialMemoryCache || []) - const sharedCache = new Map(options.initialSharedCache || []) + // Mock cache storage with CacheEntry structure (includes TTL support) + const memoryCache = new Map(options.initialMemoryCache || []) + const sharedCache = new Map(options.initialSharedCache || []) const persistCache = new Map(options.initialPersistCache || []) + // Active hooks tracking + const activeHooks = new Set() + // Mock subscribers const subscribers = new Map>() + // Shared cache ready state + let sharedCacheReady = true + const sharedCacheReadyCallbacks: Array<() => void> = [] + + // Helper function to check TTL expiration + const isExpired = (entry: CacheEntry): boolean => { + if (entry.expireAt && Date.now() > entry.expireAt) { + return true + } + return false + } + // Helper function to notify subscribers const notifySubscribers = (key: string) => { const keySubscribers = subscribers.get(key) @@ -46,80 +64,228 @@ export const createMockCacheService = ( } const mockCacheService = { - // Memory cache methods - get: vi.fn((key: string): T | null => { - if (memoryCache.has(key)) { - return memoryCache.get(key) as T - } - // Return default values for known cache keys - const defaultValue = getDefaultValueForKey(key) - return defaultValue !== undefined ? defaultValue : null - }), + // ============ Memory Cache (Type-safe) ============ - set: vi.fn((key: string, value: T): void => { - const oldValue = memoryCache.get(key) - memoryCache.set(key, value) - if (oldValue !== value) { + get: vi.fn((key: K): UseCacheSchema[K] => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return DefaultUseCache[key] + } + if (isExpired(entry)) { + memoryCache.delete(key) notifySubscribers(key) + return DefaultUseCache[key] } + return entry.value }), - delete: vi.fn((key: string): boolean => { + set: vi.fn((key: K, value: UseCacheSchema[K], ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + memoryCache.set(key, entry) + notifySubscribers(key) + }), + + has: vi.fn((key: K): boolean => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + memoryCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + delete: vi.fn((key: K): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useCache hook`) + return false + } const existed = memoryCache.has(key) memoryCache.delete(key) if (existed) { notifySubscribers(key) } - return existed + return true }), - clear: vi.fn((): void => { - const keys = Array.from(memoryCache.keys()) - memoryCache.clear() - keys.forEach((key) => notifySubscribers(key)) + hasTTL: vi.fn((key: K): boolean => { + const entry = memoryCache.get(key) + return entry?.expireAt !== undefined }), - has: vi.fn((key: string): boolean => { - return memoryCache.has(key) - }), + // ============ Memory Cache (Casual - Dynamic Keys) ============ - size: vi.fn((): number => { - return memoryCache.size - }), - - // Shared cache methods - getShared: vi.fn((key: string): T | null => { - if (sharedCache.has(key)) { - return sharedCache.get(key) as T + getCasual: vi.fn((key: string): T | undefined => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return undefined } - const defaultValue = getDefaultSharedValueForKey(key) - return defaultValue !== undefined ? defaultValue : null - }), - - setShared: vi.fn((key: string, value: T): void => { - const oldValue = sharedCache.get(key) - sharedCache.set(key, value) - if (oldValue !== value) { - notifySubscribers(`shared:${key}`) + if (isExpired(entry)) { + memoryCache.delete(key) + notifySubscribers(key) + return undefined } + return entry.value as T }), - deleteShared: vi.fn((key: string): boolean => { + setCasual: vi.fn((key: string, value: T, ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + memoryCache.set(key, entry) + notifySubscribers(key) + }), + + hasCasual: vi.fn((key: string): boolean => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + memoryCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + deleteCasual: vi.fn((key: string): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useCache hook`) + return false + } + const existed = memoryCache.has(key) + memoryCache.delete(key) + if (existed) { + notifySubscribers(key) + } + return true + }), + + hasTTLCasual: vi.fn((key: string): boolean => { + const entry = memoryCache.get(key) + return entry?.expireAt !== undefined + }), + + // ============ Shared Cache (Type-safe) ============ + + getShared: vi.fn((key: K): SharedCacheSchema[K] | undefined => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return DefaultSharedCache[key] + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return DefaultSharedCache[key] + } + return entry.value + }), + + setShared: vi.fn((key: K, value: SharedCacheSchema[K], ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + sharedCache.set(key, entry) + notifySubscribers(key) + }), + + hasShared: vi.fn((key: K): boolean => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + deleteShared: vi.fn((key: K): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useSharedCache hook`) + return false + } const existed = sharedCache.has(key) sharedCache.delete(key) if (existed) { - notifySubscribers(`shared:${key}`) + notifySubscribers(key) } - return existed + return true }), - clearShared: vi.fn((): void => { - const keys = Array.from(sharedCache.keys()) - sharedCache.clear() - keys.forEach((key) => notifySubscribers(`shared:${key}`)) + hasSharedTTL: vi.fn((key: K): boolean => { + const entry = sharedCache.get(key) + return entry?.expireAt !== undefined }), - // Persist cache methods + // ============ Shared Cache (Casual - Dynamic Keys) ============ + + getSharedCasual: vi.fn((key: string): T | undefined => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return undefined + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return undefined + } + return entry.value as T + }), + + setSharedCasual: vi.fn((key: string, value: T, ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + sharedCache.set(key, entry) + notifySubscribers(key) + }), + + hasSharedCasual: vi.fn((key: string): boolean => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + deleteSharedCasual: vi.fn((key: string): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useSharedCache hook`) + return false + } + const existed = sharedCache.has(key) + sharedCache.delete(key) + if (existed) { + notifySubscribers(key) + } + return true + }), + + hasSharedTTLCasual: vi.fn((key: string): boolean => { + const entry = sharedCache.get(key) + return entry?.expireAt !== undefined + }), + + // ============ Persist Cache ============ + getPersist: vi.fn((key: K): RendererPersistCacheSchema[K] => { if (persistCache.has(key)) { return persistCache.get(key) as RendererPersistCacheSchema[K] @@ -128,29 +294,46 @@ export const createMockCacheService = ( }), setPersist: vi.fn((key: K, value: RendererPersistCacheSchema[K]): void => { - const oldValue = persistCache.get(key) persistCache.set(key, value) - if (oldValue !== value) { - notifySubscribers(`persist:${key}`) + notifySubscribers(key) + }), + + hasPersist: vi.fn((key: RendererPersistCacheKey): boolean => { + return persistCache.has(key) + }), + + // ============ Hook Reference Management ============ + + registerHook: vi.fn((key: string): void => { + activeHooks.add(key) + }), + + unregisterHook: vi.fn((key: string): void => { + activeHooks.delete(key) + }), + + // ============ Shared Cache Ready State ============ + + isSharedCacheReady: vi.fn((): boolean => { + return sharedCacheReady + }), + + onSharedCacheReady: vi.fn((callback: () => void): (() => void) => { + if (sharedCacheReady) { + callback() + return () => {} + } + sharedCacheReadyCallbacks.push(callback) + return () => { + const idx = sharedCacheReadyCallbacks.indexOf(callback) + if (idx >= 0) { + sharedCacheReadyCallbacks.splice(idx, 1) + } } }), - deletePersist: vi.fn((key: K): boolean => { - const existed = persistCache.has(key) - persistCache.delete(key) - if (existed) { - notifySubscribers(`persist:${key}`) - } - return existed - }), + // ============ Subscription Management ============ - clearPersist: vi.fn((): void => { - const keys = Array.from(persistCache.keys()) as RendererPersistCacheKey[] - persistCache.clear() - keys.forEach((key) => notifySubscribers(`persist:${key}`)) - }), - - // Subscription methods subscribe: vi.fn((key: string, callback: CacheSubscriber): (() => void) => { if (!subscribers.has(key)) { subscribers.set(key, new Set()) @@ -169,78 +352,52 @@ export const createMockCacheService = ( } }), - unsubscribe: vi.fn((key: string, callback?: CacheSubscriber): void => { - if (callback) { - const keySubscribers = subscribers.get(key) - if (keySubscribers) { - keySubscribers.delete(callback) - if (keySubscribers.size === 0) { - subscribers.delete(key) - } - } - } else { - subscribers.delete(key) - } + notifySubscribers: vi.fn((key: string): void => { + notifySubscribers(key) }), - // Hook reference tracking (for advanced cache management) - addHookReference: vi.fn((): void => { - // Mock implementation - in real service this prevents cache cleanup + // ============ Lifecycle ============ + + cleanup: vi.fn((): void => { + memoryCache.clear() + sharedCache.clear() + persistCache.clear() + activeHooks.clear() + subscribers.clear() }), - removeHookReference: vi.fn((): void => { - // Mock implementation - }), + // ============ Internal State Access for Testing ============ - // Utility methods - getAllKeys: vi.fn((): string[] => { - return Array.from(memoryCache.keys()) - }), - - getStats: vi.fn(() => ({ - memorySize: memoryCache.size, - sharedSize: sharedCache.size, - persistSize: persistCache.size, - subscriberCount: subscribers.size - })), - - // Internal state access for testing _getMockState: () => ({ memoryCache: new Map(memoryCache), sharedCache: new Map(sharedCache), persistCache: new Map(persistCache), - subscribers: new Map(subscribers) + activeHooks: new Set(activeHooks), + subscribers: new Map(subscribers), + sharedCacheReady }), _resetMockState: () => { memoryCache.clear() sharedCache.clear() persistCache.clear() + activeHooks.clear() subscribers.clear() + sharedCacheReady = true + }, + + _setSharedCacheReady: (ready: boolean) => { + sharedCacheReady = ready + if (ready) { + sharedCacheReadyCallbacks.forEach((cb) => cb()) + sharedCacheReadyCallbacks.length = 0 + } } } return mockCacheService } -/** - * Get default value for cache keys based on schema - */ -function getDefaultValueForKey(key: string): any { - // Try to match against known cache schemas - if (key in DefaultUseCache) { - return DefaultUseCache[key as UseCacheKey] - } - return undefined -} - -function getDefaultSharedValueForKey(key: string): any { - if (key in DefaultSharedCache) { - return DefaultSharedCache[key as SharedCacheKey] - } - return undefined -} - // Default mock instance export const mockCacheService = createMockCacheService() @@ -251,47 +408,91 @@ export const MockCacheService = { return mockCacheService } - // Delegate all methods to the mock - get(key: string): T | null { - return mockCacheService.get(key) as T | null + // ============ Memory Cache (Type-safe) ============ + get(key: K): UseCacheSchema[K] { + return mockCacheService.get(key) } - set(key: string, value: T): void { - return mockCacheService.set(key, value) + set(key: K, value: UseCacheSchema[K], ttl?: number): void { + return mockCacheService.set(key, value, ttl) } - delete(key: string): boolean { - return mockCacheService.delete(key) - } - - clear(): void { - return mockCacheService.clear() - } - - has(key: string): boolean { + has(key: K): boolean { return mockCacheService.has(key) } - size(): number { - return mockCacheService.size() + delete(key: K): boolean { + return mockCacheService.delete(key) } - getShared(key: string): T | null { - return mockCacheService.getShared(key) as T | null + hasTTL(key: K): boolean { + return mockCacheService.hasTTL(key) } - setShared(key: string, value: T): void { - return mockCacheService.setShared(key, value) + // ============ Memory Cache (Casual) ============ + getCasual(key: string): T | undefined { + return mockCacheService.getCasual(key) as T | undefined } - deleteShared(key: string): boolean { + setCasual(key: string, value: T, ttl?: number): void { + return mockCacheService.setCasual(key, value, ttl) + } + + hasCasual(key: string): boolean { + return mockCacheService.hasCasual(key) + } + + deleteCasual(key: string): boolean { + return mockCacheService.deleteCasual(key) + } + + hasTTLCasual(key: string): boolean { + return mockCacheService.hasTTLCasual(key) + } + + // ============ Shared Cache (Type-safe) ============ + getShared(key: K): SharedCacheSchema[K] | undefined { + return mockCacheService.getShared(key) + } + + setShared(key: K, value: SharedCacheSchema[K], ttl?: number): void { + return mockCacheService.setShared(key, value, ttl) + } + + hasShared(key: K): boolean { + return mockCacheService.hasShared(key) + } + + deleteShared(key: K): boolean { return mockCacheService.deleteShared(key) } - clearShared(): void { - return mockCacheService.clearShared() + hasSharedTTL(key: K): boolean { + return mockCacheService.hasSharedTTL(key) } + // ============ Shared Cache (Casual) ============ + getSharedCasual(key: string): T | undefined { + return mockCacheService.getSharedCasual(key) as T | undefined + } + + setSharedCasual(key: string, value: T, ttl?: number): void { + return mockCacheService.setSharedCasual(key, value, ttl) + } + + hasSharedCasual(key: string): boolean { + return mockCacheService.hasSharedCasual(key) + } + + deleteSharedCasual(key: string): boolean { + return mockCacheService.deleteSharedCasual(key) + } + + hasSharedTTLCasual(key: string): boolean { + return mockCacheService.hasSharedTTLCasual(key) + } + + // ============ Persist Cache ============ getPersist(key: K): RendererPersistCacheSchema[K] { return mockCacheService.getPersist(key) } @@ -300,36 +501,40 @@ export const MockCacheService = { return mockCacheService.setPersist(key, value) } - deletePersist(key: K): boolean { - return mockCacheService.deletePersist(key) + hasPersist(key: RendererPersistCacheKey): boolean { + return mockCacheService.hasPersist(key) } - clearPersist(): void { - return mockCacheService.clearPersist() + // ============ Hook Reference Management ============ + registerHook(key: string): void { + return mockCacheService.registerHook(key) } + unregisterHook(key: string): void { + return mockCacheService.unregisterHook(key) + } + + // ============ Ready State ============ + isSharedCacheReady(): boolean { + return mockCacheService.isSharedCacheReady() + } + + onSharedCacheReady(callback: () => void): () => void { + return mockCacheService.onSharedCacheReady(callback) + } + + // ============ Subscription ============ subscribe(key: string, callback: CacheSubscriber): () => void { return mockCacheService.subscribe(key, callback) } - unsubscribe(key: string, callback?: CacheSubscriber): void { - return mockCacheService.unsubscribe(key, callback) + notifySubscribers(key: string): void { + return mockCacheService.notifySubscribers(key) } - addHookReference(): void { - return mockCacheService.addHookReference() - } - - removeHookReference(): void { - return mockCacheService.removeHookReference() - } - - getAllKeys(): string[] { - return mockCacheService.getAllKeys() - } - - getStats() { - return mockCacheService.getStats() + // ============ Lifecycle ============ + cleanup(): void { + return mockCacheService.cleanup() } }, cacheService: mockCacheService @@ -349,7 +554,7 @@ export const MockCacheUtils = { } }) if ('_resetMockState' in mockCacheService) { - ;(mockCacheService as any)._resetMockState() + mockCacheService._resetMockState() } }, @@ -357,33 +562,52 @@ export const MockCacheUtils = { * Set initial cache state for testing */ setInitialState: (state: { - memory?: Array<[string, any]> - shared?: Array<[string, any]> + memory?: Array<[string, any, number?]> // [key, value, ttl?] + shared?: Array<[string, any, number?]> persist?: Array<[RendererPersistCacheKey, any]> }) => { - if ('_resetMockState' in mockCacheService) { - ;(mockCacheService as any)._resetMockState() - } + mockCacheService._resetMockState() - state.memory?.forEach(([key, value]) => mockCacheService.set(key, value)) - state.shared?.forEach(([key, value]) => mockCacheService.setShared(key, value)) - state.persist?.forEach(([key, value]) => mockCacheService.setPersist(key, value)) + state.memory?.forEach(([key, value, ttl]) => { + mockCacheService.setCasual(key, value, ttl) + }) + state.shared?.forEach(([key, value, ttl]) => { + mockCacheService.setSharedCasual(key, value, ttl) + }) + state.persist?.forEach(([key, value]) => { + mockCacheService.setPersist(key, value) + }) }, /** * Get current mock state for inspection */ getCurrentState: () => { - if ('_getMockState' in mockCacheService) { - return (mockCacheService as any)._getMockState() - } - return null + return mockCacheService._getMockState() }, /** * Simulate cache events for testing subscribers */ - triggerCacheChange: (key: string, value: any) => { - mockCacheService.set(key, value) + triggerCacheChange: (key: string, value: any, ttl?: number) => { + mockCacheService.setCasual(key, value, ttl) + }, + + /** + * Set shared cache ready state for testing + */ + setSharedCacheReady: (ready: boolean) => { + mockCacheService._setSharedCacheReady(ready) + }, + + /** + * Simulate TTL expiration by manipulating cache entries + */ + simulateTTLExpiration: (key: string) => { + const state = mockCacheService._getMockState() + const entry = state.memoryCache.get(key) || state.sharedCache.get(key) + if (entry) { + entry.expireAt = Date.now() - 1000 // Set to expired + } } } diff --git a/tests/__mocks__/renderer/DataApiService.ts b/tests/__mocks__/renderer/DataApiService.ts index e1bc58ed36..da1c04b415 100644 --- a/tests/__mocks__/renderer/DataApiService.ts +++ b/tests/__mocks__/renderer/DataApiService.ts @@ -1,63 +1,21 @@ -import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiTypes' -import type { DataResponse } from '@shared/data/api/apiTypes' +import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' +import type { SubscriptionCallback, SubscriptionOptions } from '@shared/data/api/apiTypes' +import { SubscriptionEvent } from '@shared/data/api/apiTypes' import { vi } from 'vitest' /** * Mock DataApiService for testing * Provides a comprehensive mock of the DataApiService with realistic behavior + * Matches the actual DataApiService interface from src/renderer/src/data/DataApiService.ts */ -// Mock response utilities -const createMockResponse = (data: T, success = true): DataResponse => ({ - id: 'mock-id', - status: success ? 200 : 500, - data, - ...(success ? {} : { error: { code: 'MOCK_ERROR', message: 'Mock error', details: {}, status: 500 } }) -}) - -const createMockError = (message: string): DataResponse => ({ - id: 'mock-error-id', - status: 500, - error: { - code: 'MOCK_ERROR', - message, - details: {}, - status: 500 - } -}) - /** - * Mock implementation of DataApiService + * Retry options interface (matches actual) */ -export const createMockDataApiService = (customBehavior: Partial = {}): ApiClient => { - const mockService: ApiClient = { - // HTTP Methods - get: vi.fn(async (path: ConcreteApiPaths) => { - // Default mock behavior - return raw data based on path - return getMockDataForPath(path, 'GET') as any - }), - - post: vi.fn(async (path: ConcreteApiPaths) => { - return getMockDataForPath(path, 'POST') as any - }), - - put: vi.fn(async (path: ConcreteApiPaths) => { - return getMockDataForPath(path, 'PUT') as any - }), - - patch: vi.fn(async (path: ConcreteApiPaths) => { - return getMockDataForPath(path, 'PATCH') as any - }), - - delete: vi.fn(async () => { - return { deleted: true } as any - }), - - // Apply custom behavior overrides - ...customBehavior - } - - return mockService +interface RetryOptions { + maxRetries: number + retryDelay: number + backoffMultiplier: number } /** @@ -136,6 +94,157 @@ function getMockDataForPath(path: ConcreteApiPaths, method: string): any { } } +/** + * Create a mock DataApiService with realistic behavior + */ +export const createMockDataApiService = (customBehavior: Partial> = {}) => { + // Track subscriptions + const subscriptions = new Map< + string, + { + callback: SubscriptionCallback + options: SubscriptionOptions + } + >() + + // Retry configuration + let retryOptions: RetryOptions = { + maxRetries: 2, + retryDelay: 1000, + backoffMultiplier: 2 + } + + const mockService = { + // ============ HTTP Methods ============ + + get: vi.fn( + async ( + path: TPath, + _options?: { query?: any; headers?: Record } + ) => { + return getMockDataForPath(path, 'GET') + } + ), + + post: vi.fn( + async ( + path: TPath, + _options: { body?: any; query?: Record; headers?: Record } + ) => { + return getMockDataForPath(path, 'POST') + } + ), + + put: vi.fn( + async ( + path: TPath, + _options: { body: any; query?: Record; headers?: Record } + ) => { + return getMockDataForPath(path, 'PUT') + } + ), + + patch: vi.fn( + async ( + path: TPath, + _options: { body?: any; query?: Record; headers?: Record } + ) => { + return getMockDataForPath(path, 'PATCH') + } + ), + + delete: vi.fn( + async ( + _path: TPath, + _options?: { query?: Record; headers?: Record } + ) => { + return { deleted: true } + } + ), + + // ============ Subscription ============ + + subscribe: vi.fn((options: SubscriptionOptions, callback: SubscriptionCallback): (() => void) => { + const subscriptionId = `sub_${Date.now()}_${Math.random()}` + + subscriptions.set(subscriptionId, { + callback: callback as SubscriptionCallback, + options + }) + + // Return unsubscribe function + return () => { + subscriptions.delete(subscriptionId) + } + }), + + // ============ Retry Configuration ============ + + configureRetry: vi.fn((options: Partial): void => { + retryOptions = { + ...retryOptions, + ...options + } + }), + + getRetryConfig: vi.fn((): RetryOptions => { + return { ...retryOptions } + }), + + // ============ Request Management (Deprecated) ============ + + /** + * @deprecated This method has no effect with direct IPC + */ + cancelRequest: vi.fn((_requestId: string): void => { + // No-op - direct IPC requests cannot be cancelled + }), + + /** + * @deprecated This method has no effect with direct IPC + */ + cancelAllRequests: vi.fn((): void => { + // No-op - direct IPC requests cannot be cancelled + }), + + // ============ Statistics ============ + + getRequestStats: vi.fn(() => ({ + pendingRequests: 0, + activeSubscriptions: subscriptions.size + })), + + // ============ Internal State Access for Testing ============ + + _getMockState: () => ({ + subscriptions: new Map(subscriptions), + retryOptions: { ...retryOptions } + }), + + _resetMockState: () => { + subscriptions.clear() + retryOptions = { + maxRetries: 2, + retryDelay: 1000, + backoffMultiplier: 2 + } + }, + + _triggerSubscription: (path: string, data: any, event: SubscriptionEvent) => { + subscriptions.forEach(({ callback, options }) => { + if (options.path === path) { + callback(data, event) + } + }) + }, + + // Apply custom behavior overrides + ...customBehavior + } + + return mockService +} + // Default mock instance export const mockDataApiService = createMockDataApiService() @@ -146,26 +255,69 @@ export const MockDataApiService = { return mockDataApiService } - // Instance methods delegate to the mock - async get(path: ConcreteApiPaths, options?: any) { + // ============ HTTP Methods ============ + async get( + path: TPath, + options?: { query?: any; headers?: Record } + ) { return mockDataApiService.get(path, options) } - async post(path: ConcreteApiPaths, options?: any) { + async post( + path: TPath, + options: { body?: any; query?: Record; headers?: Record } + ) { return mockDataApiService.post(path, options) } - async put(path: ConcreteApiPaths, options?: any) { + async put( + path: TPath, + options: { body: any; query?: Record; headers?: Record } + ) { return mockDataApiService.put(path, options) } - async patch(path: ConcreteApiPaths, options?: any) { + async patch( + path: TPath, + options: { body?: any; query?: Record; headers?: Record } + ) { return mockDataApiService.patch(path, options) } - async delete(path: ConcreteApiPaths, options?: any) { + async delete( + path: TPath, + options?: { query?: Record; headers?: Record } + ) { return mockDataApiService.delete(path, options) } + + // ============ Subscription ============ + subscribe(options: SubscriptionOptions, callback: SubscriptionCallback): () => void { + return mockDataApiService.subscribe(options, callback) + } + + // ============ Retry Configuration ============ + configureRetry(options: Partial): void { + return mockDataApiService.configureRetry(options) + } + + getRetryConfig(): RetryOptions { + return mockDataApiService.getRetryConfig() + } + + // ============ Request Management ============ + cancelRequest(requestId: string): void { + return mockDataApiService.cancelRequest(requestId) + } + + cancelAllRequests(): void { + return mockDataApiService.cancelAllRequests() + } + + // ============ Statistics ============ + getRequestStats() { + return mockDataApiService.getRequestStats() + } }, dataApiService: mockDataApiService } @@ -183,20 +335,20 @@ export const MockDataApiUtils = { method.mockClear() } }) + mockDataApiService._resetMockState() }, /** * Set custom response for a specific path and method */ - setCustomResponse: (path: ConcreteApiPaths, method: string, response: any) => { - const methodFn = mockDataApiService[method.toLowerCase() as keyof ApiClient] as any + setCustomResponse: (path: ConcreteApiPaths, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', response: any) => { + const methodFn = mockDataApiService[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'] if (vi.isMockFunction(methodFn)) { methodFn.mockImplementation(async (requestPath: string) => { if (requestPath === path) { - return createMockResponse(response) + return response } - // Fall back to default behavior - return createMockResponse(getMockDataForPath(requestPath as ConcreteApiPaths, method)) + return getMockDataForPath(requestPath as ConcreteApiPaths, method) }) } }, @@ -204,15 +356,14 @@ export const MockDataApiUtils = { /** * Set error response for a specific path and method */ - setErrorResponse: (path: ConcreteApiPaths, method: string, errorMessage: string) => { - const methodFn = mockDataApiService[method.toLowerCase() as keyof ApiClient] as any + setErrorResponse: (path: ConcreteApiPaths, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', error: Error) => { + const methodFn = mockDataApiService[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'] if (vi.isMockFunction(methodFn)) { methodFn.mockImplementation(async (requestPath: string) => { if (requestPath === path) { - return createMockError(errorMessage) + throw error } - // Fall back to default behavior - return createMockResponse(getMockDataForPath(requestPath as ConcreteApiPaths, method)) + return getMockDataForPath(requestPath as ConcreteApiPaths, method) }) } }, @@ -220,16 +371,30 @@ export const MockDataApiUtils = { /** * Get call count for a specific method */ - getCallCount: (method: keyof ApiClient): number => { - const methodFn = mockDataApiService[method] as any + getCallCount: (method: 'get' | 'post' | 'put' | 'patch' | 'delete'): number => { + const methodFn = mockDataApiService[method] return vi.isMockFunction(methodFn) ? methodFn.mock.calls.length : 0 }, /** * Get calls for a specific method */ - getCalls: (method: keyof ApiClient): any[] => { - const methodFn = mockDataApiService[method] as any + getCalls: (method: 'get' | 'post' | 'put' | 'patch' | 'delete'): any[] => { + const methodFn = mockDataApiService[method] return vi.isMockFunction(methodFn) ? methodFn.mock.calls : [] + }, + + /** + * Trigger a subscription callback for testing + */ + triggerSubscription: (path: string, data: any, event: SubscriptionEvent = SubscriptionEvent.UPDATED) => { + mockDataApiService._triggerSubscription(path, data, event) + }, + + /** + * Get current mock state + */ + getCurrentState: () => { + return mockDataApiService._getMockState() } } diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts index 53048738c8..a1af44d41c 100644 --- a/tests/__mocks__/renderer/useDataApi.ts +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -1,38 +1,14 @@ -import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' -import type { PaginatedResponse } from '@shared/data/api/apiTypes' +import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' +import type { ConcreteApiPaths, PaginatedResponse } from '@shared/data/api/apiTypes' +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/src/data/hooks/useDataApi.ts */ -// Mock SWR response interface -interface MockSWRResponse { - data?: T - error?: Error - isLoading: boolean - isValidating: boolean - mutate: (data?: T | Promise | ((data: T) => T)) => Promise -} - -// Mock mutation response interface -interface MockMutationResponse { - data?: T - error?: Error - isMutating: boolean - trigger: (...args: any[]) => Promise - reset: () => void -} - -// Mock paginated response interface -interface MockPaginatedResponse extends MockSWRResponse> { - loadMore: () => void - isLoadingMore: boolean - hasMore: boolean - items: T[] -} - /** * Create mock data based on API path */ @@ -70,98 +46,121 @@ function createMockDataForPath(path: ConcreteApiPaths): any { /** * Mock useQuery hook + * Matches actual signature: useQuery(path, options?) => { data, loading, error, refetch, mutate } */ export const mockUseQuery = vi.fn( - (path: TPath | null, _query?: any, options?: any): MockSWRResponse => { - const isLoading = options?.initialLoading ?? false - const hasError = options?.shouldError ?? false - - if (hasError) { + ( + path: TPath, + options?: { + query?: QueryParamsForPath + enabled?: boolean + swrOptions?: any + } + ): { + data?: ResponseForPath + loading: boolean + error?: Error + refetch: () => void + mutate: KeyedMutator> + } => { + // Check if query is disabled + if (options?.enabled === false) { return { data: undefined, - error: new Error(`Mock error for ${path}`), - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator> } } - const mockData = path ? createMockDataForPath(path) : undefined + const mockData = createMockDataForPath(path) return { - data: mockData, + data: mockData as ResponseForPath, + loading: false, error: undefined, - isLoading, - isValidating: false, - mutate: vi.fn().mockResolvedValue(mockData) + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator> } } ) /** * Mock useMutation hook + * Matches actual signature: useMutation(method, path, options?) => { mutate, loading, error } */ export const mockUseMutation = vi.fn( ( - path: TPath, method: TMethod, - options?: any - ): MockMutationResponse => { - const isMutating = options?.initialMutating ?? false - const hasError = options?.shouldError ?? false - - const mockTrigger = vi.fn(async (...args: any[]) => { - if (hasError) { - throw new Error(`Mock mutation error for ${method} ${path}`) + _path: TPath, + _options?: { + onSuccess?: (data: ResponseForPath) => void + onError?: (error: Error) => void + revalidate?: boolean | string[] + optimistic?: boolean + optimisticData?: ResponseForPath + } + ): { + mutate: (data?: { + body?: BodyForPath + query?: QueryParamsForPath + }) => Promise> + loading: boolean + error: Error | undefined + } => { + const mockMutate = vi.fn( + async (_data?: { body?: BodyForPath; query?: QueryParamsForPath }) => { + // 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 + } } - - // Simulate different responses based on method - switch (method) { - case 'POST': - return { id: 'new_item', created: true, ...args[0] } - case 'PUT': - case 'PATCH': - return { id: 'updated_item', updated: true, ...args[0] } - case 'DELETE': - return { deleted: true } - default: - return { success: true } - } - }) + ) return { - data: undefined, - error: undefined, - isMutating, - trigger: mockTrigger, - reset: vi.fn() + mutate: mockMutate, + loading: false, + error: undefined } } ) /** * Mock usePaginatedQuery hook + * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset } */ export const mockUsePaginatedQuery = vi.fn( - (path: TPath | null, _query?: any, options?: any): MockPaginatedResponse => { - const isLoading = options?.initialLoading ?? false - const isLoadingMore = options?.initialLoadingMore ?? false - const hasError = options?.shouldError ?? false - - if (hasError) { - return { - data: undefined, - error: new Error(`Mock paginated error for ${path}`), - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined), - loadMore: vi.fn(), - isLoadingMore: false, - hasMore: false, - items: [] - } + ( + path: TPath, + _options?: { + query?: Omit, 'page' | 'limit'> + limit?: number + swrOptions?: any } - + ): ResponseForPath extends PaginatedResponse + ? { + items: T[] + total: number + page: number + loading: boolean + error?: Error + hasMore: boolean + hasPrev: boolean + prevPage: () => void + nextPage: () => void + refresh: () => void + reset: () => void + } + : never => { const mockItems = path ? [ { id: 'item1', name: 'Mock Item 1' }, @@ -170,52 +169,61 @@ export const mockUsePaginatedQuery = vi.fn( ] : [] - const mockData: PaginatedResponse = { + return { items: mockItems, total: mockItems.length, page: 1, - pageCount: 1, - hasNext: false, - hasPrev: false - } - - return { - data: mockData, + loading: false, error: undefined, - isLoading, - isValidating: false, - mutate: vi.fn().mockResolvedValue(mockData), - loadMore: vi.fn(), - isLoadingMore, - hasMore: mockData.hasNext, - items: mockItems - } + hasMore: false, + hasPrev: false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), + reset: vi.fn() + } as unknown as ResponseForPath extends PaginatedResponse + ? { + items: T[] + total: number + page: number + loading: boolean + error?: Error + hasMore: boolean + hasPrev: boolean + prevPage: () => void + nextPage: () => void + refresh: () => void + reset: () => void + } + : never } ) /** * Mock useInvalidateCache hook + * Matches actual signature: useInvalidateCache() => (keys?) => Promise */ -export const mockUseInvalidateCache = vi.fn(() => { - return { - invalidate: vi.fn(async () => { - // Mock cache invalidation - return Promise.resolve() - }), - invalidateAll: vi.fn(async () => { - // Mock invalidate all caches - return Promise.resolve() - }) - } +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): Promise => { - // Mock prefetch - return mock data - return createMockDataForPath(_path) -}) +export const mockPrefetch = vi.fn( + async ( + path: TPath, + _options?: { + query?: QueryParamsForPath + } + ): Promise> => { + return createMockDataForPath(path) as ResponseForPath + } +) /** * Export all mocks as a unified module @@ -246,27 +254,26 @@ export const MockUseDataApiUtils = { /** * Set up useQuery to return specific data */ - mockQueryData: (path: ConcreteApiPaths, data: T) => { - mockUseQuery.mockImplementation((queryPath, query, options) => { + mockQueryData: (path: TPath, data: ResponseForPath) => { + mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data, + loading: false, error: undefined, - isLoading: false, - isValidating: false, + refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(data) } } // Default behavior for other paths - return ( - mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { - data: undefined, - error: undefined, - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) - } - ) + const defaultData = createMockDataForPath(queryPath) + return { + data: defaultData, + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, @@ -274,25 +281,24 @@ export const MockUseDataApiUtils = { * Set up useQuery to return loading state */ mockQueryLoading: (path: ConcreteApiPaths) => { - mockUseQuery.mockImplementation((queryPath, query, options) => { + mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data: undefined, + loading: true, error: undefined, - isLoading: true, - isValidating: true, + refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) } } - return ( - mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { - data: undefined, - error: undefined, - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) - } - ) + const defaultData = createMockDataForPath(queryPath) + return { + data: defaultData, + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, @@ -300,77 +306,139 @@ export const MockUseDataApiUtils = { * Set up useQuery to return error state */ mockQueryError: (path: ConcreteApiPaths, error: Error) => { - mockUseQuery.mockImplementation((queryPath, query, options) => { + mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data: undefined, + loading: false, error, - isLoading: false, - isValidating: false, + refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) } } - return ( - mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { - data: undefined, - error: undefined, - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) - } - ) + const defaultData = createMockDataForPath(queryPath) + return { + data: defaultData, + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, /** - * Set up useMutation to simulate success + * Set up useMutation to simulate success with specific result */ - mockMutationSuccess: (path: ConcreteApiPaths, method: string, result: T) => { - mockUseMutation.mockImplementation((mutationPath, mutationMethod, options) => { + mockMutationSuccess: ( + method: TMethod, + path: TPath, + result: ResponseForPath + ) => { + mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - data: undefined, - error: undefined, - isMutating: false, - trigger: vi.fn().mockResolvedValue(result), - reset: vi.fn() + mutate: vi.fn().mockResolvedValue(result), + loading: false, + error: undefined } } - return ( - mockUseMutation.getMockImplementation()?.(mutationPath, mutationMethod, options) || { - data: undefined, - error: undefined, - isMutating: false, - trigger: vi.fn().mockResolvedValue({}), - reset: vi.fn() - } - ) + // Default behavior + return { + mutate: vi.fn().mockResolvedValue({ success: true }), + loading: false, + error: undefined + } }) }, /** * Set up useMutation to simulate error */ - mockMutationError: (path: ConcreteApiPaths, method: string, error: Error) => { - mockUseMutation.mockImplementation((mutationPath, mutationMethod, options) => { + mockMutationError: ( + method: TMethod, + path: ConcreteApiPaths, + error: Error + ) => { + mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - data: undefined, + mutate: vi.fn().mockRejectedValue(error), + loading: false, + error: undefined + } + } + // Default behavior + return { + mutate: vi.fn().mockResolvedValue({ success: true }), + loading: false, + error: undefined + } + }) + }, + + /** + * Set up useMutation to be in loading state + */ + mockMutationLoading: ( + method: TMethod, + path: ConcreteApiPaths + ) => { + mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { + if (mutationPath === path && mutationMethod === method) { + return { + mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves + loading: true, + error: undefined + } + } + // Default behavior + return { + mutate: vi.fn().mockResolvedValue({ success: true }), + loading: false, + error: undefined + } + }) + }, + + /** + * Set up usePaginatedQuery to return specific items + */ + mockPaginatedData: ( + path: TPath, + items: any[], + options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean } + ) => { + mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => { + if (queryPath === path) { + return { + items, + total: options?.total ?? items.length, + page: options?.page ?? 1, + loading: false, error: undefined, - isMutating: false, - trigger: vi.fn().mockRejectedValue(error), + hasMore: options?.hasMore ?? false, + hasPrev: options?.hasPrev ?? false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), reset: vi.fn() } } - return ( - mockUseMutation.getMockImplementation()?.(mutationPath, mutationMethod, options) || { - data: undefined, - error: undefined, - isMutating: false, - trigger: vi.fn().mockResolvedValue({}), - reset: vi.fn() - } - ) + // Default behavior + return { + items: [], + total: 0, + page: 1, + loading: false, + error: undefined, + hasMore: false, + hasPrev: false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), + reset: vi.fn() + } }) } }