/** * Tests for CacheManager * * Tests LRU cache functionality, TTL expiration, memory pressure handling, * and cache statistics. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CacheManager, TieredCacheManager } from '../cache-manager.js'; describe('CacheManager', () => { let cache: CacheManager; beforeEach(() => { cache = new CacheManager({ maxSize: 100, ttl: 60000, // 1 minute }); }); afterEach(() => { cache.shutdown(); }); describe('basic operations', () => { it('should set and get values', () => { cache.set('key1', 'value1'); expect(cache.get('key1')).toBe('value1'); }); it('should return null for missing keys', () => { expect(cache.get('nonexistent')).toBeNull(); }); it('should delete values', () => { cache.set('key1', 'value1'); expect(cache.delete('key1')).toBe(true); expect(cache.get('key1')).toBeNull(); }); it('should return false when deleting nonexistent key', () => { expect(cache.delete('nonexistent')).toBe(false); }); it('should check if key exists', () => { cache.set('key1', 'value1'); expect(cache.has('key1')).toBe(true); expect(cache.has('nonexistent')).toBe(false); }); it('should clear all entries', () => { cache.set('key1', 'value1'); cache.set('key2', 'value2'); cache.clear(); expect(cache.size).toBe(0); expect(cache.get('key1')).toBeNull(); }); it('should return all keys', () => { cache.set('key1', 'value1'); cache.set('key2', 'value2'); const keys = cache.keys(); expect(keys).toContain('key1'); expect(keys).toContain('key2'); }); it('should update existing entries', () => { cache.set('key1', 'value1'); cache.set('key1', 'value2'); expect(cache.get('key1')).toBe('value2'); expect(cache.size).toBe(1); }); }); describe('LRU eviction', () => { it('should evict least recently used when at capacity', () => { const smallCache = new CacheManager({ maxSize: 3, ttl: 60000 }); smallCache.set('key1', 'value1'); smallCache.set('key2', 'value2'); smallCache.set('key3', 'value3'); // Access key1 to make it recently used smallCache.get('key1'); // Add new entry, should evict key2 (LRU) smallCache.set('key4', 'value4'); expect(smallCache.get('key1')).toBe('value1'); expect(smallCache.get('key2')).toBeNull(); // Evicted expect(smallCache.get('key3')).toBe('value3'); expect(smallCache.get('key4')).toBe('value4'); smallCache.shutdown(); }); it('should move accessed items to front of LRU', () => { const smallCache = new CacheManager({ maxSize: 3, ttl: 60000 }); smallCache.set('key1', 'value1'); smallCache.set('key2', 'value2'); smallCache.set('key3', 'value3'); // Access key1 multiple times smallCache.get('key1'); smallCache.get('key1'); // Add two new entries smallCache.set('key4', 'value4'); smallCache.set('key5', 'value5'); // key1 should still exist, key2 and key3 should be evicted expect(smallCache.get('key1')).toBe('value1'); expect(smallCache.has('key2')).toBe(false); expect(smallCache.has('key3')).toBe(false); smallCache.shutdown(); }); }); describe('TTL expiration', () => { it('should expire entries after TTL', async () => { const shortTtlCache = new CacheManager({ maxSize: 100, ttl: 50, // 50ms TTL }); shortTtlCache.set('key1', 'value1'); expect(shortTtlCache.get('key1')).toBe('value1'); // Wait for expiration await new Promise((resolve) => setTimeout(resolve, 100)); expect(shortTtlCache.get('key1')).toBeNull(); shortTtlCache.shutdown(); }); it('should respect custom TTL per entry', async () => { cache.set('short', 'value1', 50); // 50ms TTL cache.set('long', 'value2', 5000); // 5s TTL await new Promise((resolve) => setTimeout(resolve, 100)); expect(cache.get('short')).toBeNull(); // Expired expect(cache.get('long')).toBe('value2'); // Still valid }); it('should not return expired entries via has()', async () => { const shortTtlCache = new CacheManager({ maxSize: 100, ttl: 50, }); shortTtlCache.set('key1', 'value1'); expect(shortTtlCache.has('key1')).toBe(true); await new Promise((resolve) => setTimeout(resolve, 100)); expect(shortTtlCache.has('key1')).toBe(false); shortTtlCache.shutdown(); }); }); describe('memory pressure', () => { it('should evict when memory limit exceeded', () => { const memoryCache = new CacheManager({ maxSize: 1000, maxMemory: 100, // Very small memory limit ttl: 60000, }); // Add entries until memory is exceeded for (let i = 0; i < 10; i++) { memoryCache.set(`key${i}`, 'a'.repeat(20)); // Each ~40 bytes } // Should have evicted some entries expect(memoryCache.size).toBeLessThan(10); memoryCache.shutdown(); }); }); describe('statistics', () => { it('should track hits and misses', () => { cache.set('key1', 'value1'); cache.get('key1'); // Hit cache.get('key1'); // Hit cache.get('nonexistent'); // Miss const stats = cache.getStats(); expect(stats.hits).toBe(2); expect(stats.misses).toBe(1); expect(stats.hitRate).toBeCloseTo(2 / 3); }); it('should track evictions', () => { const smallCache = new CacheManager({ maxSize: 2, ttl: 60000 }); smallCache.set('key1', 'value1'); smallCache.set('key2', 'value2'); smallCache.set('key3', 'value3'); // Evicts key1 const stats = smallCache.getStats(); expect(stats.evictions).toBe(1); smallCache.shutdown(); }); it('should track size and memory usage', () => { cache.set('key1', 'value1'); cache.set('key2', 'value2'); const stats = cache.getStats(); expect(stats.size).toBe(2); expect(stats.memoryUsage).toBeGreaterThan(0); }); }); describe('getOrSet', () => { it('should return cached value if exists', async () => { cache.set('key1', 'cached'); const loader = vi.fn().mockResolvedValue('loaded'); const result = await cache.getOrSet('key1', loader); expect(result).toBe('cached'); expect(loader).not.toHaveBeenCalled(); }); it('should load and cache value if not exists', async () => { const loader = vi.fn().mockResolvedValue('loaded'); const result = await cache.getOrSet('key1', loader); expect(result).toBe('loaded'); expect(loader).toHaveBeenCalledTimes(1); expect(cache.get('key1')).toBe('loaded'); }); }); describe('prefetch', () => { it('should load missing keys in batch', async () => { cache.set('key1', 'existing'); const loader = vi.fn().mockResolvedValue( new Map([ ['key2', 'loaded2'], ['key3', 'loaded3'], ]) ); await cache.prefetch(['key1', 'key2', 'key3'], loader); // Loader should only be called for missing keys expect(loader).toHaveBeenCalledWith(['key2', 'key3']); expect(cache.get('key1')).toBe('existing'); expect(cache.get('key2')).toBe('loaded2'); expect(cache.get('key3')).toBe('loaded3'); }); it('should not call loader if all keys exist', async () => { cache.set('key1', 'value1'); cache.set('key2', 'value2'); const loader = vi.fn(); await cache.prefetch(['key1', 'key2'], loader); expect(loader).not.toHaveBeenCalled(); }); }); describe('warmUp', () => { it('should populate cache with initial data', () => { cache.warmUp([ { key: 'key1', data: 'value1' }, { key: 'key2', data: 'value2' }, { key: 'key3', data: 'value3', ttl: 1000 }, ]); expect(cache.get('key1')).toBe('value1'); expect(cache.get('key2')).toBe('value2'); expect(cache.get('key3')).toBe('value3'); expect(cache.size).toBe(3); }); }); describe('invalidatePattern', () => { it('should invalidate keys matching string pattern', () => { cache.set('user:1', 'value1'); cache.set('user:2', 'value2'); cache.set('post:1', 'value3'); const count = cache.invalidatePattern('user:'); expect(count).toBe(2); expect(cache.has('user:1')).toBe(false); expect(cache.has('user:2')).toBe(false); expect(cache.has('post:1')).toBe(true); }); it('should invalidate keys matching regex pattern', () => { cache.set('cache:session:abc', 'value1'); cache.set('cache:session:def', 'value2'); cache.set('cache:data:xyz', 'value3'); const count = cache.invalidatePattern(/cache:session:/); expect(count).toBe(2); expect(cache.has('cache:session:abc')).toBe(false); expect(cache.has('cache:data:xyz')).toBe(true); }); }); describe('events', () => { it('should emit cache:hit event', () => { const handler = vi.fn(); cache.on('cache:hit', handler); cache.set('key1', 'value1'); cache.get('key1'); expect(handler).toHaveBeenCalledWith({ key: 'key1' }); }); it('should emit cache:miss event', () => { const handler = vi.fn(); cache.on('cache:miss', handler); cache.get('nonexistent'); expect(handler).toHaveBeenCalledWith({ key: 'nonexistent' }); }); it('should emit cache:set event', () => { const handler = vi.fn(); cache.on('cache:set', handler); cache.set('key1', 'value1'); expect(handler).toHaveBeenCalledWith({ key: 'key1', ttl: 60000 }); }); it('should emit cache:delete event', () => { const handler = vi.fn(); cache.on('cache:delete', handler); cache.set('key1', 'value1'); cache.delete('key1'); expect(handler).toHaveBeenCalledWith({ key: 'key1' }); }); }); describe('shutdown', () => { it('should clear cache and stop cleanup timer', () => { cache.set('key1', 'value1'); cache.shutdown(); expect(cache.size).toBe(0); }); }); }); describe('TieredCacheManager', () => { let tieredCache: TieredCacheManager; let l2Store: Map; beforeEach(() => { l2Store = new Map(); tieredCache = new TieredCacheManager( { maxSize: 10, ttl: 60000 }, { loader: async (key) => l2Store.get(key) ?? null, writer: async (key, value) => { l2Store.set(key, value); }, } ); }); afterEach(() => { tieredCache.shutdown(); }); describe('tiered caching', () => { it('should store in L1 cache and write through to L2', async () => { await tieredCache.set('key1', 'value1'); const result = await tieredCache.get('key1'); expect(result).toBe('value1'); expect(l2Store.get('key1')).toBe('value1'); }); it('should fall back to L2 when L1 misses', async () => { // Directly set in L2 (simulating data loaded from storage) l2Store.set('key1', 'value1'); // Should find in L2 const result = await tieredCache.get('key1'); expect(result).toBe('value1'); }); it('should promote L2 hits to L1', async () => { // Set in L2 only l2Store.set('key1', 'value1'); // First access loads from L2 await tieredCache.get('key1'); // Now should be in L1 (faster subsequent access) const stats = tieredCache.getStats(); expect(stats.size).toBe(1); }); it('should delete from L1', async () => { await tieredCache.set('key1', 'value1'); expect(tieredCache.delete('key1')).toBe(true); // Should miss in L1, but L2 still has it expect(l2Store.has('key1')).toBe(true); }); it('should clear L1 cache', async () => { await tieredCache.set('key1', 'value1'); await tieredCache.set('key2', 'value2'); tieredCache.clear(); const stats = tieredCache.getStats(); expect(stats.size).toBe(0); }); }); describe('without L2', () => { it('should work with L1 only', async () => { const l1Only = new TieredCacheManager({ maxSize: 10, ttl: 60000 }); await l1Only.set('key1', 'value1'); const result = await l1Only.get('key1'); expect(result).toBe('value1'); l1Only.shutdown(); }); it('should return null for missing keys without L2', async () => { const l1Only = new TieredCacheManager({ maxSize: 10, ttl: 60000 }); const result = await l1Only.get('nonexistent'); expect(result).toBeNull(); l1Only.shutdown(); }); }); describe('statistics', () => { it('should return L1 statistics', async () => { await tieredCache.set('key1', 'value1'); await tieredCache.get('key1'); const stats = tieredCache.getStats(); expect(stats.hits).toBeGreaterThanOrEqual(1); expect(stats.size).toBe(1); }); }); describe('events', () => { it('should emit l1:hit event', async () => { const handler = vi.fn(); tieredCache.on('l1:hit', handler); await tieredCache.set('key1', 'value1'); await tieredCache.get('key1'); expect(handler).toHaveBeenCalled(); }); it('should emit l2:hit event', async () => { const handler = vi.fn(); tieredCache.on('l2:hit', handler); l2Store.set('key1', 'value1'); await tieredCache.get('key1'); expect(handler).toHaveBeenCalledWith({ key: 'key1' }); }); it('should emit l2:write event', async () => { const handler = vi.fn(); tieredCache.on('l2:write', handler); await tieredCache.set('key1', 'value1'); expect(handler).toHaveBeenCalledWith({ key: 'key1' }); }); }); });