import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { LRUCache } from '../../src/utils/Cache.js';
import { LRU_CACHE, SUBSCRIPTION_EVENTS } from '../../src/constants.js';
import { NotificationManager } from '../../src/utils/NotificationManager.js';
describe('LRUCache', () => {
let cache: LRUCache<string, any>;
beforeEach(() => {
cache = new LRUCache<string, any>({ maxSize: 3, ttl: 1000 });
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('basic operations', () => {
it('should store and retrieve values', () => {
cache.set('key1', 'value1');
expect(cache.get('key1')).toBe('value1');
});
it('should return undefined for non-existent keys', () => {
expect(cache.get('nonexistent')).toBeUndefined();
});
it('should check if key exists', () => {
cache.set('key1', 'value1');
expect(cache.has('key1')).toBe(true);
expect(cache.has('key2')).toBe(false);
});
it('should delete values', () => {
cache.set('key1', 'value1');
expect(cache.delete('key1')).toBe(true);
expect(cache.has('key1')).toBe(false);
expect(cache.delete('key1')).toBe(false);
});
it('should clear all values', () => {
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.clear();
expect(cache.size()).toBe(0);
expect(cache.has('key1')).toBe(false);
expect(cache.has('key2')).toBe(false);
});
});
describe('LRU eviction', () => {
it('should evict least recently used item when cache is full', () => {
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.set('key3', 'value3');
cache.set('key4', 'value4'); // Should evict key1
expect(cache.has('key1')).toBe(false);
expect(cache.has('key2')).toBe(true);
expect(cache.has('key3')).toBe(true);
expect(cache.has('key4')).toBe(true);
});
it('should update LRU order on get', () => {
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.set('key3', 'value3');
cache.get('key1'); // Makes key1 most recently used
cache.set('key4', 'value4'); // Should evict key2
expect(cache.has('key1')).toBe(true);
expect(cache.has('key2')).toBe(false);
expect(cache.has('key3')).toBe(true);
expect(cache.has('key4')).toBe(true);
});
it('should update LRU order on set for existing key', () => {
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.set('key3', 'value3');
cache.set('key1', 'updated'); // Makes key1 most recently used
cache.set('key4', 'value4'); // Should evict key2
expect(cache.get('key1')).toBe('updated');
expect(cache.has('key2')).toBe(false);
});
});
describe('TTL expiration', () => {
it('should expire items after TTL', () => {
cache.set('key1', 'value1');
expect(cache.get('key1')).toBe('value1');
vi.advanceTimersByTime(1001);
expect(cache.get('key1')).toBeUndefined();
expect(cache.has('key1')).toBe(false);
});
it('should allow custom TTL per item', () => {
cache.set('key1', 'value1', 500);
cache.set('key2', 'value2', 2000);
vi.advanceTimersByTime(600);
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBe('value2');
vi.advanceTimersByTime(1500);
expect(cache.get('key2')).toBeUndefined();
});
it('should not expire if TTL is 0', () => {
const noTtlCache = new LRUCache<string, string>({ maxSize: 3, ttl: 0 });
noTtlCache.set('key1', 'value1');
vi.advanceTimersByTime(10000);
expect(noTtlCache.get('key1')).toBe('value1');
});
});
describe('statistics', () => {
it('should track cache hits and misses', () => {
cache.set('key1', 'value1');
cache.get('key1'); // hit
cache.get('key1'); // hit
cache.get('key2'); // miss
const stats = cache.getStats();
expect(stats.hits).toBe(2);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBeCloseTo(0.667, 3);
});
it('should reset statistics', () => {
cache.set('key1', 'value1');
cache.get('key1');
cache.get('key2');
cache.resetStats();
const stats = cache.getStats();
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
expect(stats.hitRate).toBe(0);
});
});
describe('constants usage', () => {
it('should use NO_EXPIRATION constant for TTL checks', () => {
// Test that cache with ttl: 0 uses the NO_EXPIRATION constant correctly
const noExpirationCache = new LRUCache<string, string>({ maxSize: 3, ttl: LRU_CACHE.NO_EXPIRATION });
noExpirationCache.set('key1', 'value1');
vi.advanceTimersByTime(100000);
expect(noExpirationCache.get('key1')).toBe('value1');
});
it('should use NO_EXPIRATION for expiry calculations', () => {
// Test that custom TTL of 0 means no expiration
cache.set('key1', 'value1', LRU_CACHE.NO_EXPIRATION);
vi.advanceTimersByTime(100000);
expect(cache.get('key1')).toBe('value1');
});
});
describe('notification integration', () => {
let notificationManager: NotificationManager;
let notificationSpy: ReturnType<typeof vi.spyOn>;
let cacheWithNotifications: LRUCache<string, string>;
beforeEach(() => {
// Reset notification manager to ensure clean state
NotificationManager.reset();
notificationManager = NotificationManager.getInstance();
notificationSpy = vi.spyOn(notificationManager, 'notifyCacheInvalidation');
cacheWithNotifications = new LRUCache<string, string>({
maxSize: 3,
ttl: 1000,
notificationManager,
instanceId: 'test-cache'
});
});
afterEach(() => {
notificationSpy.mockRestore();
NotificationManager.reset();
});
it('should notify when deleting a key', () => {
cacheWithNotifications.set('key1', 'value1');
cacheWithNotifications.delete('key1');
expect(notificationSpy).toHaveBeenCalledWith('key1', {
instanceId: 'test-cache',
operation: 'delete'
});
});
it('should notify when clearing cache', () => {
cacheWithNotifications.set('key1', 'value1');
cacheWithNotifications.set('key2', 'value2');
cacheWithNotifications.clear();
expect(notificationSpy).toHaveBeenCalledWith('*', {
instanceId: 'test-cache',
operation: 'clear'
});
});
it('should notify when cache entries expire', () => {
cacheWithNotifications.set('key1', 'value1', 100); // 100ms TTL
vi.advanceTimersByTime(150);
// Trigger cleanup by calling size()
cacheWithNotifications.size();
expect(notificationSpy).toHaveBeenCalledWith('key1', {
instanceId: 'test-cache',
operation: 'expire'
});
});
it('should not notify when notification manager is not provided', () => {
const cacheWithoutNotifications = new LRUCache<string, string>({
maxSize: 3,
ttl: 1000
});
cacheWithoutNotifications.set('key1', 'value1');
cacheWithoutNotifications.delete('key1');
// Should not throw and should not call notification manager
expect(notificationSpy).not.toHaveBeenCalled();
});
it('should notify when LRU eviction occurs', () => {
// Fill cache to capacity
cacheWithNotifications.set('key1', 'value1');
cacheWithNotifications.set('key2', 'value2');
cacheWithNotifications.set('key3', 'value3');
// This should evict key1 (least recently used)
cacheWithNotifications.set('key4', 'value4');
expect(notificationSpy).toHaveBeenCalledWith('key1', {
instanceId: 'test-cache',
operation: 'evict'
});
});
});
describe('maxEntrySize enforcement', () => {
let limitedCache: LRUCache<string, string>;
beforeEach(() => {
limitedCache = new LRUCache<string, string>({
maxSize: 3,
ttl: 1000,
maxEntrySize: 100 // 100 bytes max per entry
});
});
it('should reject entries that exceed maxEntrySize', () => {
const smallValue = 'small';
const largeValue = 'x'.repeat(200); // 200 bytes, exceeds limit
// Small value should be accepted
limitedCache.set('small-key', smallValue);
expect(limitedCache.get('small-key')).toBe(smallValue);
// Large value should be rejected (not stored)
limitedCache.set('large-key', largeValue);
expect(limitedCache.get('large-key')).toBeUndefined();
expect(limitedCache.has('large-key')).toBe(false);
});
it('should allow entries at exactly the maxEntrySize limit', () => {
const exactValue = 'x'.repeat(100); // Exactly 100 bytes
limitedCache.set('exact-key', exactValue);
expect(limitedCache.get('exact-key')).toBe(exactValue);
});
it('should work normally when maxEntrySize is not specified', () => {
const unlimitedCache = new LRUCache<string, string>({
maxSize: 3,
ttl: 1000
// No maxEntrySize specified
});
const largeValue = 'x'.repeat(1000);
unlimitedCache.set('large-key', largeValue);
expect(unlimitedCache.get('large-key')).toBe(largeValue);
});
it('should handle non-string values by measuring JSON size', () => {
const objectCache = new LRUCache<string, object>({
maxSize: 3,
ttl: 1000,
maxEntrySize: 50
});
const smallObject = { name: 'test' }; // Small JSON
const largeObject = {
data: 'x'.repeat(100),
moreData: 'y'.repeat(100)
}; // Large JSON when serialized
objectCache.set('small', smallObject);
expect(objectCache.get('small')).toEqual(smallObject);
objectCache.set('large', largeObject);
expect(objectCache.get('large')).toBeUndefined();
});
it('should update cache stats correctly when entries are rejected', () => {
const largeValue = 'x'.repeat(200);
// Try to set large value (should be rejected)
limitedCache.set('large-key', largeValue);
// Try to get it (should be miss since it was never stored)
const result = limitedCache.get('large-key');
expect(result).toBeUndefined();
const stats = limitedCache.getStats();
expect(stats.misses).toBe(1);
expect(stats.hits).toBe(0);
});
});
});