import { executeWithPersistentCache } from '../cache-with-storage'; import { setGlobalStorage, getGlobalStorage } from '@/native/api/storage'; // Mock native bridge to prevent invokeNative errors jest.mock('@jolibox/native-bridge', () => ({ invokeNative: jest.fn(), applyNative: jest.fn() })); // Mock storage functions jest.mock('@/native/api/storage', () => ({ setGlobalStorage: jest.fn(), getGlobalStorage: jest.fn() })); const mockSetGlobalStorage = setGlobalStorage as jest.MockedFunction; const mockGetGlobalStorage = getGlobalStorage as jest.MockedFunction; describe('executeWithPersistentCache', () => { const mockExecutor = jest.fn(); const testCacheKey = 'test-key'; const testData = { value: 'test-data' }; const mockTimestamp = 1640000000000; beforeEach(() => { jest.clearAllMocks(); jest.spyOn(Date, 'now').mockReturnValue(mockTimestamp); jest.spyOn(console, 'log').mockImplementation(); jest.spyOn(console, 'warn').mockImplementation(); // Mock 成功的 setGlobalStorage 响应 mockSetGlobalStorage.mockResolvedValue({ code: 'SUCCESS', message: 'success' }); }); afterEach(() => { jest.restoreAllMocks(); }); describe('Cache miss scenarios', () => { it('should execute function and cache result when no cached data exists', async () => { // Setup: no cached data mockGetGlobalStorage.mockResolvedValue({ code: 'SUCCESS', message: 'success', data: null }); mockExecutor.mockResolvedValue(testData); const result = await executeWithPersistentCache(testCacheKey, mockExecutor); expect(result).toEqual(testData); expect(mockExecutor).toHaveBeenCalledTimes(1); expect(mockSetGlobalStorage).toHaveBeenCalledWith('joli_cache_test-key', testData); expect(mockSetGlobalStorage).toHaveBeenCalledWith( 'joli_cache_test-key_timestamp', mockTimestamp.toString() ); }); it('should execute function when cached data is expired', async () => { const expiredTimestamp = (mockTimestamp - 60 * 60 * 1000).toString(); // 1 hour ago mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: expiredTimestamp }); mockExecutor.mockResolvedValue({ value: 'fresh-data' }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor, { cacheDuration: 30 * 60 * 1000 // 30 minutes }); expect(result).toEqual({ value: 'fresh-data' }); expect(mockExecutor).toHaveBeenCalledTimes(1); // Should clear expired cache expect(mockSetGlobalStorage).toHaveBeenCalledWith('joli_cache_test-key', null); expect(mockSetGlobalStorage).toHaveBeenCalledWith('joli_cache_test-key_timestamp', null); }); }); describe('Cache hit scenarios', () => { it('should return cached data when valid cache exists', async () => { const validTimestamp = (mockTimestamp - 10 * 60 * 1000).toString(); // 10 minutes ago mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: validTimestamp }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor, { cacheDuration: 30 * 60 * 1000 // 30 minutes }); expect(result).toEqual(testData); expect(mockExecutor).not.toHaveBeenCalled(); }); it('should return cached data and update in background when cache is valid but not never-expire', async () => { const validTimestamp = (mockTimestamp - 10 * 60 * 1000).toString(); mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: validTimestamp }); // 创建一个新的 executor 实例以避免跨测试的状态污染 const backgroundExecutor = jest.fn().mockResolvedValue({ value: 'updated-data' }); const result = await executeWithPersistentCache(testCacheKey, backgroundExecutor, { cacheDuration: 30 * 60 * 1000 }); expect(result).toEqual(testData); // 等待更长时间确保背景更新完成 await new Promise((resolve) => setTimeout(resolve, 100)); expect(backgroundExecutor).toHaveBeenCalledTimes(1); }); }); describe('Never-expire cache', () => { it('should return cached data without background update when cacheDuration is Infinity', async () => { const oldTimestamp = (mockTimestamp - 24 * 60 * 60 * 1000).toString(); // 24 hours ago mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: oldTimestamp }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor, { cacheDuration: Infinity }); expect(result).toEqual(testData); expect(mockExecutor).not.toHaveBeenCalled(); // Wait to ensure no background update await new Promise((resolve) => setTimeout(resolve, 100)); expect(mockExecutor).not.toHaveBeenCalled(); }); it('should return cached data without background update when cacheDuration is -1', async () => { const oldTimestamp = (mockTimestamp - 24 * 60 * 60 * 1000).toString(); mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: oldTimestamp }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor, { cacheDuration: -1 }); expect(result).toEqual(testData); expect(mockExecutor).not.toHaveBeenCalled(); }); it('should use default Infinity cacheDuration when not specified', async () => { const oldTimestamp = (mockTimestamp - 24 * 60 * 60 * 1000).toString(); mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: oldTimestamp }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor); expect(result).toEqual(testData); expect(mockExecutor).not.toHaveBeenCalled(); }); }); describe('Storage options', () => { it('should use custom storage key prefix', async () => { mockGetGlobalStorage.mockResolvedValue({ code: 'SUCCESS', message: 'success', data: null }); mockExecutor.mockResolvedValue(testData); await executeWithPersistentCache(testCacheKey, mockExecutor, { storageKeyPrefix: 'custom_prefix_' }); expect(mockGetGlobalStorage).toHaveBeenCalledWith('custom_prefix_test-key'); expect(mockGetGlobalStorage).toHaveBeenCalledWith('custom_prefix_test-key_timestamp'); expect(mockSetGlobalStorage).toHaveBeenCalledWith('custom_prefix_test-key', testData); expect(mockSetGlobalStorage).toHaveBeenCalledWith( 'custom_prefix_test-key_timestamp', mockTimestamp.toString() ); }); }); describe('Error handling', () => { it('should handle getGlobalStorage error and fallback to executor', async () => { mockGetGlobalStorage.mockRejectedValue(new Error('Storage read error')); mockExecutor.mockResolvedValue(testData); const result = await executeWithPersistentCache(testCacheKey, mockExecutor); expect(result).toEqual(testData); expect(mockExecutor).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenCalledWith( '[Cache] Failed to read from localStorage for key:', testCacheKey, expect.any(Error) ); }); it('should handle setGlobalStorage error but still return data', async () => { mockGetGlobalStorage.mockResolvedValue({ code: 'SUCCESS', message: 'success', data: null }); mockSetGlobalStorage.mockRejectedValue(new Error('Storage write error')); mockExecutor.mockResolvedValue(testData); const result = await executeWithPersistentCache(testCacheKey, mockExecutor); expect(result).toEqual(testData); expect(console.warn).toHaveBeenCalledWith( '[Cache] Failed to save to localStorage for key:', testCacheKey, expect.any(Error) ); }); it('should propagate executor errors', async () => { mockGetGlobalStorage.mockResolvedValue({ code: 'SUCCESS', message: 'success', data: null }); const executorError = new Error('Executor failed'); mockExecutor.mockRejectedValue(executorError); await expect(executeWithPersistentCache(testCacheKey, mockExecutor)).rejects.toThrow('Executor failed'); expect(console.warn).toHaveBeenCalledWith( '[Cache] Executor failed for key:', testCacheKey, executorError ); }); it('should handle JSON parse error in cached data', async () => { mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: 'invalid-json{' }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: mockTimestamp.toString() }); mockExecutor.mockResolvedValue(testData); const result = await executeWithPersistentCache(testCacheKey, mockExecutor); expect(result).toEqual(testData); expect(mockExecutor).toHaveBeenCalledTimes(1); }); it('should handle background update errors gracefully', async () => { const validTimestamp = (mockTimestamp - 10 * 60 * 1000).toString(); mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: validTimestamp }); const backgroundExecutor = jest.fn().mockRejectedValue(new Error('Background update failed')); const result = await executeWithPersistentCache(testCacheKey, backgroundExecutor, { cacheDuration: 30 * 60 * 1000 }); expect(result).toEqual(testData); // Wait for background update to fail await new Promise((resolve) => setTimeout(resolve, 100)); expect(console.warn).toHaveBeenCalledWith( '[Cache] Background cache update failed for key:', testCacheKey, expect.any(Error) ); }); }); describe('Edge cases', () => { it('should handle missing timestamp in cache', async () => { mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: null }); // Missing timestamp mockExecutor.mockResolvedValue({ value: 'fresh-data' }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor, { cacheDuration: 30 * 60 * 1000 }); expect(result).toEqual({ value: 'fresh-data' }); expect(mockExecutor).toHaveBeenCalledTimes(1); }); it('should handle invalid timestamp format', async () => { mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: 'invalid-timestamp' }); mockExecutor.mockResolvedValue({ value: 'fresh-data' }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor, { cacheDuration: 30 * 60 * 1000 }); expect(result).toEqual({ value: 'fresh-data' }); expect(mockExecutor).toHaveBeenCalledTimes(1); }); it('should handle zero cacheDuration (immediate expiration)', async () => { mockGetGlobalStorage .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: JSON.stringify(testData) }) .mockResolvedValueOnce({ code: 'SUCCESS', message: 'success', data: mockTimestamp.toString() }); mockExecutor.mockResolvedValue({ value: 'fresh-data' }); const result = await executeWithPersistentCache(testCacheKey, mockExecutor, { cacheDuration: 0 }); expect(result).toEqual({ value: 'fresh-data' }); expect(mockExecutor).toHaveBeenCalledTimes(1); }); }); });