import { DefaultMainPersistCache } from '@shared/data/cache/cacheSchemas' import type { InferSharedCacheValue, MainPersistCacheKey, MainPersistCacheSchema, ProcessKey, SharedCacheKey } from '@shared/data/cache/cacheSchemas' import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes' import { isEqual } from 'es-toolkit/compat' import { vi } from 'vitest' /** * Mock CacheService for main process testing * Simulates the complete main process CacheService functionality */ // Mock cache storage const mockMainCache = new Map() // Mock shared cache storage const mockSharedCache = new Map() // Mock main-process persist cache storage (backs getPersist/setPersist/hasPersist) const mockPersistCache = new Map() // Mock broadcast tracking const mockBroadcastCalls: Array<{ message: CacheSyncMessage; senderWindowId?: number }> = [] /** * Mock CacheService class */ export class MockMainCacheService { private static instance: MockMainCacheService private initialized = false private constructor() {} public static getInstance(): MockMainCacheService { if (!MockMainCacheService.instance) { MockMainCacheService.instance = new MockMainCacheService() } return MockMainCacheService.instance } // Mock initialization public initialize = vi.fn(async (): Promise => { this.initialized = true }) // Mock main process cache methods public get = vi.fn((key: string): T | undefined => { const entry = mockMainCache.get(key) if (!entry) return undefined // Check TTL (lazy cleanup) if (entry.expireAt && Date.now() > entry.expireAt) { mockMainCache.delete(key) return undefined } return entry.value as T }) public set = vi.fn((key: string, value: T, ttl?: number): void => { const entry: CacheEntry = { value, expireAt: ttl ? Date.now() + ttl : undefined } mockMainCache.set(key, entry) }) public has = vi.fn((key: string): boolean => { const entry = mockMainCache.get(key) if (!entry) return false // Check TTL if (entry.expireAt && Date.now() > entry.expireAt) { mockMainCache.delete(key) return false } return true }) public delete = vi.fn((key: string): boolean => { return mockMainCache.delete(key) }) // ============ Shared Cache Methods ============ public getShared = vi.fn((key: K): InferSharedCacheValue | undefined => { const entry = mockSharedCache.get(key) if (!entry) return undefined // Check TTL (lazy cleanup) if (entry.expireAt && Date.now() > entry.expireAt) { mockSharedCache.delete(key) return undefined } return entry.value as InferSharedCacheValue }) public setShared = vi.fn((key: K, value: InferSharedCacheValue, ttl?: number): void => { const entry: CacheEntry = { value, expireAt: ttl ? Date.now() + ttl : undefined } mockSharedCache.set(key, entry) // Track broadcast for testing mockBroadcastCalls.push({ message: { type: 'shared', key, value, expireAt: entry.expireAt } }) }) public hasShared = vi.fn((key: K): boolean => { const entry = mockSharedCache.get(key) if (!entry) return false // Check TTL if (entry.expireAt && Date.now() > entry.expireAt) { mockSharedCache.delete(key) return false } return true }) public deleteShared = vi.fn((key: K): boolean => { if (!mockSharedCache.has(key)) { return true } mockSharedCache.delete(key) // Track broadcast for testing mockBroadcastCalls.push({ message: { type: 'shared', key, value: undefined } }) return true }) // ============ Persist Cache Methods ============ // Faithful to the real main persist tier: getPersist returns the stored // override else the schema default (never undefined), setPersist installs an // override, hasPersist reports deviation from the default. public getPersist = vi.fn((key: K): MainPersistCacheSchema[K] => { return ( mockPersistCache.has(key) ? mockPersistCache.get(key) : DefaultMainPersistCache[key] ) as MainPersistCacheSchema[K] }) public setPersist = vi.fn((key: K, value: MainPersistCacheSchema[K]): void => { mockPersistCache.set(key, value) }) public hasPersist = vi.fn((key: K): boolean => { return mockPersistCache.has(key) && !isEqual(mockPersistCache.get(key), DefaultMainPersistCache[key]) }) // ============ Subscription Methods ============ // These are call-tracking stubs — the mock does NOT replicate fire semantics. // Each call returns a fresh vi.fn() unsubscribe stub, useful for verifying // `registerDisposable(cacheService.subscribeChange(...))` wiring in tests. public subscribeChange = vi.fn( (_key: string, _callback: (newValue: T | undefined, oldValue: T | undefined) => void): (() => void) => vi.fn() ) public subscribeSharedChange = vi.fn( ( _key: K, _callback: ( newValue: InferSharedCacheValue | undefined, oldValue: InferSharedCacheValue | undefined, concreteKey: ProcessKey ) => void ): (() => void) => vi.fn() ) // Mock cleanup public cleanup = vi.fn((): void => { mockMainCache.clear() mockSharedCache.clear() mockPersistCache.clear() mockBroadcastCalls.length = 0 }) // Private methods exposed for testing // These methods are mocked but not exposed to avoid TypeScript unused warnings } // Mock singleton instance const mockInstance = MockMainCacheService.getInstance() /** * Export mock service */ export const MockMainCacheServiceExport = { CacheService: MockMainCacheService, cacheService: mockInstance } /** * Utility functions for testing */ export const MockMainCacheServiceUtils = { /** * Reset all mock call counts and state */ resetMocks: () => { // Reset all method mocks Object.values(mockInstance).forEach((method) => { if (vi.isMockFunction(method)) { method.mockClear() } }) // Reset cache state mockMainCache.clear() mockSharedCache.clear() mockPersistCache.clear() mockBroadcastCalls.length = 0 // Reset initialized state mockInstance['initialized'] = false }, /** * Set cache value for testing */ setCacheValue: (key: string, value: T, ttl?: number) => { const entry: CacheEntry = { value, expireAt: ttl ? Date.now() + ttl : undefined } mockMainCache.set(key, entry) }, /** * Get cache value for testing */ getCacheValue: (key: string): T | undefined => { const entry = mockMainCache.get(key) if (!entry) return undefined // Check TTL if (entry.expireAt && Date.now() > entry.expireAt) { mockMainCache.delete(key) return undefined } return entry.value as T }, /** * Set initialization state for testing */ setInitialized: (initialized: boolean) => { mockInstance['initialized'] = initialized }, /** * Get current initialization state */ isInitialized: (): boolean => { return mockInstance['initialized'] }, /** * Get all cache entries for testing */ getAllCacheEntries: (): Map => { return new Map(mockMainCache) }, // ============ Shared Cache Utilities ============ /** * Set shared cache value for testing */ setSharedCacheValue: (key: K, value: InferSharedCacheValue, ttl?: number) => { const entry: CacheEntry = { value, expireAt: ttl ? Date.now() + ttl : undefined } mockSharedCache.set(key, entry) }, /** * Get shared cache value for testing */ getSharedCacheValue: (key: K): InferSharedCacheValue | undefined => { const entry = mockSharedCache.get(key) if (!entry) return undefined // Check TTL if (entry.expireAt && Date.now() > entry.expireAt) { mockSharedCache.delete(key) return undefined } return entry.value as InferSharedCacheValue }, /** * Get all shared cache entries for testing */ getAllSharedCacheEntries: (): Map => { return new Map(mockSharedCache) }, /** * Simulate shared cache expiration for testing */ simulateSharedCacheExpiration: (key: string) => { const entry = mockSharedCache.get(key) if (entry) { entry.expireAt = Date.now() - 1000 // Set to expired } }, /** * Get broadcast call history for testing */ getBroadcastHistory: (): Array<{ message: CacheSyncMessage; senderWindowId?: number }> => { return [...mockBroadcastCalls] }, /** * Simulate cache sync broadcast */ simulateCacheSync: (message: CacheSyncMessage, senderWindowId?: number) => { mockBroadcastCalls.push({ message, senderWindowId }) }, /** * Set multiple cache values at once */ setMultipleCacheValues: (values: Array<[string, any, number?]>) => { values.forEach(([key, value, ttl]) => { const entry: CacheEntry = { value, expireAt: ttl ? Date.now() + ttl : undefined } mockMainCache.set(key, entry) }) }, /** * Simulate cache expiration for testing */ simulateCacheExpiration: (key: string) => { const entry = mockMainCache.get(key) if (entry) { entry.expireAt = Date.now() - 1000 // Set to expired } }, /** * Get cache statistics */ getCacheStats: () => ({ totalEntries: mockMainCache.size, sharedEntries: mockSharedCache.size, broadcastCalls: mockBroadcastCalls.length, keys: Array.from(mockMainCache.keys()), sharedKeys: Array.from(mockSharedCache.keys()) }), /** * Mock initialization error */ simulateInitializationError: (error: Error) => { mockInstance.initialize.mockRejectedValue(error) }, /** * Get mock call counts for debugging */ getMockCallCounts: () => ({ initialize: mockInstance.initialize.mock.calls.length, get: mockInstance.get.mock.calls.length, set: mockInstance.set.mock.calls.length, has: mockInstance.has.mock.calls.length, delete: mockInstance.delete.mock.calls.length, getShared: mockInstance.getShared.mock.calls.length, setShared: mockInstance.setShared.mock.calls.length, hasShared: mockInstance.hasShared.mock.calls.length, deleteShared: mockInstance.deleteShared.mock.calls.length, subscribeChange: mockInstance.subscribeChange.mock.calls.length, subscribeSharedChange: mockInstance.subscribeSharedChange.mock.calls.length, cleanup: mockInstance.cleanup.mock.calls.length }) }