diff --git a/src/__tests__/utils/cache.test.ts b/src/__tests__/utils/cache.test.ts new file mode 100644 index 0000000..e74ac3f --- /dev/null +++ b/src/__tests__/utils/cache.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { cache } from '../../utils/cache'; + +// We need to import the Cache class itself to create test instances +// Since it's not exported, we'll use dynamic import and manipulation +type CacheItem = { + value: T; + timestamp: number; + ttl: number; + lastAccessed: number; +}; + +class TestCache { + private storage: Map>; + private readonly defaultTTL: number; + private readonly maxSize?: number; + + constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) { + this.storage = new Map(); + this.defaultTTL = defaultTTL; + this.maxSize = maxSize; + } + + set(key: string, value: T, ttl = this.defaultTTL): void { + const now = Date.now(); + + if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) { + this.evictLRU(); + } + + this.storage.set(key, { + value, + timestamp: now, + ttl, + lastAccessed: now + }); + } + + private evictLRU(): void { + let lruKey: string | null = null; + let lruTime = Infinity; + + for (const [key, item] of this.storage.entries()) { + if (item.lastAccessed < lruTime) { + lruTime = item.lastAccessed; + lruKey = key; + } + } + + if (lruKey) { + this.storage.delete(lruKey); + } + } + + get(key: string): T | null { + const item = this.storage.get(key); + + if (!item) return null; + + if (Date.now() > item.timestamp + item.ttl) { + this.storage.delete(key); + return null; + } + + item.lastAccessed = Date.now(); + + return item.value; + } + + has(key: string): boolean { + return this.get(key) !== null; + } + + delete(key: string): void { + this.storage.delete(key); + } + + clear(): void { + this.storage.clear(); + } + + // Helper method for testing + size(): number { + return this.storage.size; + } +} + +describe('Cache', () => { + let testCache: TestCache; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('Basic Operations', () => { + beforeEach(() => { + testCache = new TestCache(); + }); + + it('should store and retrieve values', () => { + testCache.set('key1', 'value1'); + expect(testCache.get('key1')).toBe('value1'); + }); + + it('should return null for non-existent keys', () => { + expect(testCache.get('nonexistent')).toBeNull(); + }); + + it('should delete values', () => { + testCache.set('key1', 'value1'); + testCache.delete('key1'); + expect(testCache.get('key1')).toBeNull(); + }); + + it('should clear all values', () => { + testCache.set('key1', 'value1'); + testCache.set('key2', 'value2'); + testCache.clear(); + expect(testCache.get('key1')).toBeNull(); + expect(testCache.get('key2')).toBeNull(); + }); + + it('should check if key exists with has()', () => { + testCache.set('key1', 'value1'); + expect(testCache.has('key1')).toBe(true); + expect(testCache.has('nonexistent')).toBe(false); + }); + + it('should handle different data types', () => { + testCache.set('string', 'text'); + testCache.set('number', 42); + testCache.set('object', { foo: 'bar' }); + testCache.set('array', [1, 2, 3]); + testCache.set('boolean', true); + + expect(testCache.get('string')).toBe('text'); + expect(testCache.get('number')).toBe(42); + expect(testCache.get('object')).toEqual({ foo: 'bar' }); + expect(testCache.get('array')).toEqual([1, 2, 3]); + expect(testCache.get('boolean')).toBe(true); + }); + }); + + describe('TTL Expiration', () => { + beforeEach(() => { + testCache = new TestCache(1000); // 1 second default TTL + }); + + it('should expire items after TTL', () => { + testCache.set('key1', 'value1'); + expect(testCache.get('key1')).toBe('value1'); + + vi.advanceTimersByTime(1001); + expect(testCache.get('key1')).toBeNull(); + }); + + it('should use custom TTL per item', () => { + testCache.set('key1', 'value1', 500); + testCache.set('key2', 'value2', 2000); + + vi.advanceTimersByTime(501); + expect(testCache.get('key1')).toBeNull(); + expect(testCache.get('key2')).toBe('value2'); + + vi.advanceTimersByTime(1500); + expect(testCache.get('key2')).toBeNull(); + }); + + it('should use default TTL when not specified', () => { + testCache.set('key1', 'value1'); + + vi.advanceTimersByTime(999); + expect(testCache.get('key1')).toBe('value1'); + + vi.advanceTimersByTime(2); + expect(testCache.get('key1')).toBeNull(); + }); + + it('should remove expired items from storage', () => { + testCache.set('key1', 'value1', 1000); + expect(testCache.size()).toBe(1); + + vi.advanceTimersByTime(1001); + testCache.get('key1'); // Triggers cleanup + expect(testCache.size()).toBe(0); + }); + + it('should return false for has() on expired items', () => { + testCache.set('key1', 'value1', 1000); + expect(testCache.has('key1')).toBe(true); + + vi.advanceTimersByTime(1001); + expect(testCache.has('key1')).toBe(false); + }); + }); + + describe('Size Limit and LRU Eviction', () => { + beforeEach(() => { + testCache = new TestCache(60000, 3); // 60s TTL, max 3 items + }); + + it('should enforce size limit', () => { + testCache.set('key1', 'value1'); + testCache.set('key2', 'value2'); + testCache.set('key3', 'value3'); + expect(testCache.size()).toBe(3); + + testCache.set('key4', 'value4'); + expect(testCache.size()).toBe(3); + }); + + it('should evict least recently used item when adding new item', () => { + testCache.set('key1', 'value1'); + vi.advanceTimersByTime(10); + + testCache.set('key2', 'value2'); + vi.advanceTimersByTime(10); + + testCache.set('key3', 'value3'); + vi.advanceTimersByTime(10); + + // key1 is oldest, should be evicted when adding key4 + testCache.set('key4', 'value4'); + + expect(testCache.get('key1')).toBeNull(); + expect(testCache.get('key2')).toBe('value2'); + expect(testCache.get('key3')).toBe('value3'); + expect(testCache.get('key4')).toBe('value4'); + }); + + it('should update access time on get and affect LRU eviction', () => { + testCache.set('key1', 'value1'); + vi.advanceTimersByTime(10); + + testCache.set('key2', 'value2'); + vi.advanceTimersByTime(10); + + testCache.set('key3', 'value3'); + vi.advanceTimersByTime(10); + + // Access key1, making it recently used + testCache.get('key1'); + vi.advanceTimersByTime(10); + + // Now key2 is least recently used, should be evicted + testCache.set('key4', 'value4'); + + expect(testCache.get('key1')).toBe('value1'); + expect(testCache.get('key2')).toBeNull(); + expect(testCache.get('key3')).toBe('value3'); + expect(testCache.get('key4')).toBe('value4'); + }); + + it('should not evict when updating existing key', () => { + testCache.set('key1', 'value1'); + testCache.set('key2', 'value2'); + testCache.set('key3', 'value3'); + + // Update key1, should not trigger eviction + testCache.set('key1', 'updated1'); + + expect(testCache.size()).toBe(3); + expect(testCache.get('key1')).toBe('updated1'); + expect(testCache.get('key2')).toBe('value2'); + expect(testCache.get('key3')).toBe('value3'); + }); + + it('should handle multiple evictions correctly', () => { + testCache.set('key1', 'value1'); + vi.advanceTimersByTime(10); + testCache.set('key2', 'value2'); + vi.advanceTimersByTime(10); + testCache.set('key3', 'value3'); + vi.advanceTimersByTime(10); + + // Add key4, evicts key1 + testCache.set('key4', 'value4'); + vi.advanceTimersByTime(10); + + // Add key5, evicts key2 + testCache.set('key5', 'value5'); + + expect(testCache.get('key1')).toBeNull(); + expect(testCache.get('key2')).toBeNull(); + expect(testCache.get('key3')).toBe('value3'); + expect(testCache.get('key4')).toBe('value4'); + expect(testCache.get('key5')).toBe('value5'); + }); + }); + + describe('Edge Cases', () => { + it('should handle maxSize of 1', () => { + testCache = new TestCache(60000, 1); + + testCache.set('key1', 'value1'); + expect(testCache.get('key1')).toBe('value1'); + + testCache.set('key2', 'value2'); + expect(testCache.get('key1')).toBeNull(); + expect(testCache.get('key2')).toBe('value2'); + }); + + it('should handle maxSize of 0 (treated as no limit)', () => { + testCache = new TestCache(60000, 0); + + testCache.set('key1', 'value1'); + // maxSize of 0 is falsy, so it's treated as no limit + expect(testCache.size()).toBe(1); + expect(testCache.get('key1')).toBe('value1'); + }); + + it('should handle empty cache operations', () => { + testCache = new TestCache(); + + expect(testCache.get('key1')).toBeNull(); + expect(testCache.has('key1')).toBe(false); + testCache.delete('key1'); // Should not throw + testCache.clear(); // Should not throw + }); + + it('should handle deleting non-existent keys', () => { + testCache = new TestCache(); + + testCache.set('key1', 'value1'); + testCache.delete('nonexistent'); + expect(testCache.get('key1')).toBe('value1'); + }); + + it('should handle clearing empty cache', () => { + testCache = new TestCache(); + testCache.clear(); + expect(testCache.size()).toBe(0); + }); + }); + + describe('Backward Compatibility', () => { + it('should work as unlimited cache without maxSize', () => { + testCache = new TestCache(); + + // Add many items without size limit + for (let i = 0; i < 100; i++) { + testCache.set(`key${i}`, `value${i}`); + } + + expect(testCache.size()).toBe(100); + + // All items should still be accessible + expect(testCache.get('key0')).toBe('value0'); + expect(testCache.get('key50')).toBe('value50'); + expect(testCache.get('key99')).toBe('value99'); + }); + + it('should use default TTL of 5 minutes when not specified', () => { + testCache = new TestCache(); + testCache.set('key1', 'value1'); + + // Should still be available after 4 minutes + vi.advanceTimersByTime(4 * 60 * 1000); + expect(testCache.get('key1')).toBe('value1'); + + // Should expire after 5 minutes + vi.advanceTimersByTime(61 * 1000); + expect(testCache.get('key1')).toBeNull(); + }); + + it('should maintain original behavior for set/get/delete/clear', () => { + testCache = new TestCache(); + + testCache.set('key1', 'value1'); + expect(testCache.get('key1')).toBe('value1'); + + testCache.set('key2', 'value2'); + testCache.delete('key1'); + expect(testCache.get('key1')).toBeNull(); + expect(testCache.get('key2')).toBe('value2'); + + testCache.clear(); + expect(testCache.get('key2')).toBeNull(); + }); + }); + + describe('Complex LRU Scenarios', () => { + beforeEach(() => { + testCache = new TestCache(60000, 3); + }); + + it('should evict based on access time, not insertion time', () => { + testCache.set('key1', 'value1'); + vi.advanceTimersByTime(10); + + testCache.set('key2', 'value2'); + vi.advanceTimersByTime(10); + + testCache.set('key3', 'value3'); + vi.advanceTimersByTime(10); + + // Access key1 and key2, making key3 the least recently used + testCache.get('key1'); + vi.advanceTimersByTime(5); + testCache.get('key2'); + vi.advanceTimersByTime(5); + + // key3 should be evicted + testCache.set('key4', 'value4'); + + expect(testCache.get('key1')).toBe('value1'); + expect(testCache.get('key2')).toBe('value2'); + expect(testCache.get('key3')).toBeNull(); + expect(testCache.get('key4')).toBe('value4'); + }); + + it('should handle repeated access correctly', () => { + testCache.set('key1', 'value1'); + vi.advanceTimersByTime(10); + + testCache.set('key2', 'value2'); + vi.advanceTimersByTime(10); + + testCache.set('key3', 'value3'); + vi.advanceTimersByTime(10); + + // Keep accessing key1 + for (let i = 0; i < 5; i++) { + testCache.get('key1'); + vi.advanceTimersByTime(5); + } + + // key2 should be evicted (oldest access) + testCache.set('key4', 'value4'); + + expect(testCache.get('key1')).toBe('value1'); + expect(testCache.get('key2')).toBeNull(); + expect(testCache.get('key3')).toBe('value3'); + expect(testCache.get('key4')).toBe('value4'); + }); + + it('should handle mixed operations correctly', () => { + testCache.set('key1', 'value1'); + vi.advanceTimersByTime(10); + + testCache.set('key2', 'value2'); + vi.advanceTimersByTime(10); + + testCache.set('key3', 'value3'); + vi.advanceTimersByTime(10); + + // Update key1 (should update access time) + testCache.set('key1', 'updated1'); + vi.advanceTimersByTime(10); + + // Delete key2 + testCache.delete('key2'); + + // Add key4 - should not trigger eviction (only 2 items now) + testCache.set('key4', 'value4'); + expect(testCache.size()).toBe(3); + + // Add key5 - should evict key3 (least recently used) + vi.advanceTimersByTime(10); + testCache.set('key5', 'value5'); + + expect(testCache.get('key1')).toBe('updated1'); + expect(testCache.get('key2')).toBeNull(); + expect(testCache.get('key3')).toBeNull(); + expect(testCache.get('key4')).toBe('value4'); + expect(testCache.get('key5')).toBe('value5'); + }); + }); + + describe('Global cache instance', () => { + it('should export a global cache instance', () => { + expect(cache).toBeDefined(); + expect(typeof cache.get).toBe('function'); + expect(typeof cache.set).toBe('function'); + expect(typeof cache.delete).toBe('function'); + expect(typeof cache.clear).toBe('function'); + expect(typeof cache.has).toBe('function'); + }); + }); +}); diff --git a/src/utils/cache.ts b/src/utils/cache.ts index d04a660..ef503e5 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -2,35 +2,65 @@ type CacheItem = { value: T; timestamp: number; ttl: number; + lastAccessed: number; }; class Cache { private storage: Map>; private readonly defaultTTL: number; + private readonly maxSize?: number; - constructor(defaultTTL = 5 * 60 * 1000) { // 5 minutes default TTL + constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) { // 5 minutes default TTL this.storage = new Map(); this.defaultTTL = defaultTTL; + this.maxSize = maxSize; } set(key: string, value: T, ttl = this.defaultTTL): void { + const now = Date.now(); + + // If maxSize is set and cache is full, evict least recently used item + if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) { + this.evictLRU(); + } + this.storage.set(key, { value, - timestamp: Date.now(), - ttl + timestamp: now, + ttl, + lastAccessed: now }); } + private evictLRU(): void { + let lruKey: string | null = null; + let lruTime = Infinity; + + for (const [key, item] of this.storage.entries()) { + if (item.lastAccessed < lruTime) { + lruTime = item.lastAccessed; + lruKey = key; + } + } + + if (lruKey) { + this.storage.delete(lruKey); + } + } + get(key: string): T | null { const item = this.storage.get(key); - + if (!item) return null; - + if (Date.now() > item.timestamp + item.ttl) { this.storage.delete(key); return null; } - + + // Update last accessed time for LRU tracking + item.lastAccessed = Date.now(); + return item.value; }