import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; import Redis from 'ioredis'; import { RedisCache } from '../src/redis-cache'; import { RedisConnectionManager } from '../src/connection-manager'; import type { CacheOptions } from '../src/types'; // Mock Redis instance const createMockRedis = () => ({ status: 'ready', on: mock(() => {}), once: mock(() => {}), get: mock(async () => null), set: mock(async () => 'OK'), setex: mock(async () => 'OK'), del: mock(async () => 1), exists: mock(async () => 0), keys: mock(async () => []), ping: mock(async () => 'PONG'), ttl: mock(async () => -2), eval: mock(async () => [null, -2]), _eventCallbacks: {} as Record, _triggerEvent(event: string, ...args: any[]) { if (this._eventCallbacks[event]) { this._eventCallbacks[event](...args); } } }); // Create mock instance getter that we can control let mockConnectionManagerInstance: any = null; // Mock the connection manager mock.module('../src/connection-manager', () => ({ RedisConnectionManager: { getInstance: () => mockConnectionManagerInstance } })); describe('RedisCache', () => { let cache: RedisCache; let mockRedis: ReturnType; let mockLogger: any; let mockConnectionManager: any; beforeEach(() => { mockLogger = { info: mock(() => {}), error: mock(() => {}), warn: mock(() => {}), debug: mock(() => {}), }; mockRedis = createMockRedis(); mockConnectionManager = { getConnection: mock(() => mockRedis) }; // Set the mock instance for the module mockConnectionManagerInstance = mockConnectionManager; }); afterEach(() => { // Clear mocks mockLogger.info.mockClear(); mockLogger.error.mockClear(); mockLogger.warn.mockClear(); mockLogger.debug.mockClear(); }); describe('constructor', () => { it('should create cache with default options', () => { const options: CacheOptions = { redisConfig: { host: 'localhost', port: 6379 }, }; cache = new RedisCache(options); expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({ name: 'CACHE-SERVICE', singleton: true, redisConfig: options.redisConfig, logger: expect.any(Object), }); }); it('should use custom name and prefix', () => { const options: CacheOptions = { name: 'MyCache', keyPrefix: 'custom:', redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }; cache = new RedisCache(options); expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({ name: 'MyCache-SERVICE', singleton: true, redisConfig: options.redisConfig, logger: mockLogger, }); }); it('should handle non-shared connections', () => { const options: CacheOptions = { shared: false, redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }; // Setup event handler storage mockRedis.on = mock((event: string, handler: Function) => { mockRedis._eventCallbacks[event] = handler; }); cache = new RedisCache(options); // Should setup event handlers for non-shared expect(mockRedis.on).toHaveBeenCalledWith('connect', expect.any(Function)); expect(mockRedis.on).toHaveBeenCalledWith('ready', expect.any(Function)); expect(mockRedis.on).toHaveBeenCalledWith('error', expect.any(Function)); }); it('should sanitize prefix for connection name', () => { const options: CacheOptions = { keyPrefix: 'my-special:prefix!', redisConfig: { host: 'localhost', port: 6379 }, }; cache = new RedisCache(options); expect(mockConnectionManager.getConnection).toHaveBeenCalledWith( expect.objectContaining({ name: 'MYSPECIALPREFIX-SERVICE', }) ); }); }); describe('get', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }); }); it('should get value with prefix', async () => { const testValue = { data: 'test' }; (mockRedis.get as any).mockResolvedValue(JSON.stringify(testValue)); const result = await cache.get('mykey'); expect(mockRedis.get).toHaveBeenCalledWith('test:mykey'); expect(result).toEqual(testValue); expect(mockLogger.debug).toHaveBeenCalledWith('Cache hit', { key: 'mykey' }); }); it('should handle cache miss', async () => { (mockRedis.get as any).mockResolvedValue(null); const result = await cache.get('nonexistent'); expect(result).toBeNull(); expect(mockLogger.debug).toHaveBeenCalledWith('Cache miss', { key: 'nonexistent' }); }); it('should handle non-JSON strings', async () => { (mockRedis.get as any).mockResolvedValue('plain string'); const result = await cache.get('stringkey'); expect(result).toBe('plain string'); }); it('should handle Redis errors gracefully', async () => { (mockRedis.get as any).mockRejectedValue(new Error('Redis error')); const result = await cache.get('errorkey'); expect(result).toBeNull(); expect(mockLogger.error).toHaveBeenCalledWith( 'Redis get failed', expect.objectContaining({ error: 'Redis error' }) ); }); it('should handle not ready state', async () => { mockRedis.status = 'connecting'; const result = await cache.get('key'); expect(result).toBeNull(); expect(mockLogger.warn).toHaveBeenCalledWith( 'Redis not ready for get, using fallback' ); }); }); describe('set', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', ttl: 7200, redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }); }); it('should set value with default TTL', async () => { const value = { data: 'test' }; await cache.set('mykey', value); expect(mockRedis.setex).toHaveBeenCalledWith( 'test:mykey', 7200, JSON.stringify(value) ); }); it('should set value with custom TTL as number', async () => { await cache.set('mykey', 'value', 3600); expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 3600, 'value'); }); it('should set value with options object', async () => { await cache.set('mykey', 'value', { ttl: 1800 }); expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 1800, 'value'); }); it('should handle preserveTTL option', async () => { // Key exists with TTL (mockRedis.ttl as any).mockResolvedValue(3600); await cache.set('mykey', 'newvalue', { preserveTTL: true }); expect(mockRedis.ttl).toHaveBeenCalledWith('test:mykey'); expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 3600, 'newvalue'); }); it('should handle preserveTTL with no expiry', async () => { // Key exists with no expiry (mockRedis.ttl as any).mockResolvedValue(-1); await cache.set('mykey', 'value', { preserveTTL: true }); expect(mockRedis.set).toHaveBeenCalledWith('test:mykey', 'value'); }); it('should handle onlyIfExists option', async () => { (mockRedis.set as any).mockResolvedValue(null); await cache.set('mykey', 'value', { onlyIfExists: true }); expect(mockRedis.set).toHaveBeenCalledWith( 'test:mykey', 'value', 'EX', 7200, 'XX' ); }); it('should handle onlyIfNotExists option', async () => { (mockRedis.set as any).mockResolvedValue('OK'); await cache.set('mykey', 'value', { onlyIfNotExists: true }); expect(mockRedis.set).toHaveBeenCalledWith( 'test:mykey', 'value', 'EX', 7200, 'NX' ); }); it('should get old value when requested', async () => { const oldValue = { old: 'data' }; (mockRedis.get as any).mockResolvedValue(JSON.stringify(oldValue)); const result = await cache.set('mykey', 'newvalue', { getOldValue: true }); expect(mockRedis.get).toHaveBeenCalledWith('test:mykey'); expect(result).toEqual(oldValue); }); it('should throw error for conflicting options', async () => { await expect( cache.set('mykey', 'value', { onlyIfExists: true, onlyIfNotExists: true }) ).rejects.toThrow('Cannot specify both onlyIfExists and onlyIfNotExists'); }); it('should handle string values directly', async () => { await cache.set('mykey', 'plain string'); expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 7200, 'plain string'); }); }); describe('del', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }); }); it('should delete key with prefix', async () => { await cache.del('mykey'); expect(mockRedis.del).toHaveBeenCalledWith('test:mykey'); expect(mockLogger.debug).toHaveBeenCalledWith('Cache delete', { key: 'mykey' }); }); it('should handle delete errors gracefully', async () => { (mockRedis.del as any).mockRejectedValue(new Error('Delete failed')); await cache.del('errorkey'); expect(mockLogger.error).toHaveBeenCalledWith( 'Redis del failed', expect.objectContaining({ error: 'Delete failed' }) ); }); }); describe('exists', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', redisConfig: { host: 'localhost', port: 6379 }, }); }); it('should check key existence', async () => { (mockRedis.exists as any).mockResolvedValue(1); const result = await cache.exists('mykey'); expect(mockRedis.exists).toHaveBeenCalledWith('test:mykey'); expect(result).toBe(true); }); it('should return false for non-existent key', async () => { (mockRedis.exists as any).mockResolvedValue(0); const result = await cache.exists('nonexistent'); expect(result).toBe(false); }); }); describe('clear', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }); }); it('should clear all prefixed keys', async () => { const keys = ['test:key1', 'test:key2', 'test:key3']; (mockRedis.keys as any).mockResolvedValue(keys); await cache.clear(); expect(mockRedis.keys).toHaveBeenCalledWith('test:*'); expect(mockRedis.del).toHaveBeenCalledWith(...keys); expect(mockLogger.warn).toHaveBeenCalledWith('Cache cleared', { keysDeleted: 3 }); }); it('should handle empty cache', async () => { (mockRedis.keys as any).mockResolvedValue([]); await cache.clear(); expect(mockRedis.del).not.toHaveBeenCalled(); }); }); describe('getRaw', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }); }); it('should get value without prefix', async () => { const value = { raw: 'data' }; (mockRedis.get as any).mockResolvedValue(JSON.stringify(value)); const result = await cache.getRaw('raw:key'); expect(mockRedis.get).toHaveBeenCalledWith('raw:key'); expect(result).toEqual(value); }); it('should handle parse errors', async () => { (mockRedis.get as any).mockResolvedValue('invalid json'); const result = await cache.getRaw('badkey'); expect(result).toBe('invalid json'); expect(mockLogger.warn).toHaveBeenCalledWith( 'Cache getRaw JSON parse failed', expect.objectContaining({ key: 'badkey', valueLength: 12, }) ); }); }); describe('keys', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', redisConfig: { host: 'localhost', port: 6379 }, }); }); it('should get keys with pattern and strip prefix', async () => { (mockRedis.keys as any).mockResolvedValue([ 'test:user:1', 'test:user:2', 'test:user:3', ]); const keys = await cache.keys('user:*'); expect(mockRedis.keys).toHaveBeenCalledWith('test:user:*'); expect(keys).toEqual(['user:1', 'user:2', 'user:3']); }); }); describe('health', () => { beforeEach(() => { cache = new RedisCache({ redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }); }); it('should return true when healthy', async () => { const result = await cache.health(); expect(mockRedis.ping).toHaveBeenCalled(); expect(result).toBe(true); }); it('should return false on ping failure', async () => { (mockRedis.ping as any).mockRejectedValue(new Error('Ping failed')); const result = await cache.health(); expect(result).toBe(false); expect(mockLogger.error).toHaveBeenCalledWith( 'Redis health check failed', expect.objectContaining({ error: 'Ping failed' }) ); }); }); describe('stats', () => { beforeEach(() => { cache = new RedisCache({ redisConfig: { host: 'localhost', port: 6379 }, enableMetrics: true, }); }); it('should track cache hits', async () => { (mockRedis.get as any).mockResolvedValue('value'); await cache.get('key1'); await cache.get('key2'); const stats = cache.getStats(); expect(stats.hits).toBe(2); expect(stats.total).toBe(2); expect(stats.hitRate).toBe(1.0); }); it('should track cache misses', async () => { (mockRedis.get as any).mockResolvedValue(null); await cache.get('key1'); await cache.get('key2'); const stats = cache.getStats(); expect(stats.misses).toBe(2); expect(stats.total).toBe(2); expect(stats.hitRate).toBe(0); }); it('should track errors', async () => { mockRedis.status = 'connecting'; await cache.get('key1'); const stats = cache.getStats(); expect(stats.errors).toBe(1); }); it('should not track stats when disabled', async () => { cache = new RedisCache({ redisConfig: { host: 'localhost', port: 6379 }, enableMetrics: false, }); (mockRedis.get as any).mockResolvedValue('value'); await cache.get('key'); const stats = cache.getStats(); expect(stats.hits).toBe(0); }); }); describe('waitForReady', () => { beforeEach(() => { cache = new RedisCache({ redisConfig: { host: 'localhost', port: 6379 }, }); }); it('should resolve immediately if ready', async () => { mockRedis.status = 'ready'; await expect(cache.waitForReady(1000)).resolves.toBeUndefined(); }); it('should wait for ready event', async () => { mockRedis.status = 'connecting'; mockRedis.once = mock((event: string, handler: Function) => { if (event === 'ready') { setTimeout(() => handler(), 10); } }); await expect(cache.waitForReady(1000)).resolves.toBeUndefined(); }); it('should timeout if not ready', async () => { mockRedis.status = 'connecting'; mockRedis.once = mock(() => {}); // Don't trigger any events await expect(cache.waitForReady(100)).rejects.toThrow( 'Redis connection timeout after 100ms' ); }); it('should reject on error', async () => { mockRedis.status = 'connecting'; mockRedis.once = mock((event: string, handler: Function) => { if (event === 'error') { setTimeout(() => handler(new Error('Connection failed')), 10); } }); await expect(cache.waitForReady(1000)).rejects.toThrow('Connection failed'); }); }); describe('isReady', () => { beforeEach(() => { cache = new RedisCache({ redisConfig: { host: 'localhost', port: 6379 }, }); }); it('should return true when ready', () => { mockRedis.status = 'ready'; expect(cache.isReady()).toBe(true); }); it('should return false when not ready', () => { mockRedis.status = 'connecting'; expect(cache.isReady()).toBe(false); }); }); describe('convenience methods', () => { beforeEach(() => { cache = new RedisCache({ keyPrefix: 'test:', redisConfig: { host: 'localhost', port: 6379 }, }); }); it('should update value preserving TTL', async () => { (mockRedis.ttl as any).mockResolvedValue(3600); (mockRedis.get as any).mockResolvedValue(JSON.stringify({ old: 'value' })); const result = await cache.update('key', { new: 'value' }); expect(mockRedis.setex).toHaveBeenCalledWith( 'test:key', 3600, JSON.stringify({ new: 'value' }) ); expect(result).toEqual({ old: 'value' }); }); it('should setIfExists', async () => { (mockRedis.set as any).mockResolvedValue('OK'); (mockRedis.exists as any).mockResolvedValue(1); const result = await cache.setIfExists('key', 'value', 1800); expect(mockRedis.set).toHaveBeenCalledWith('test:key', 'value', 'EX', 1800, 'XX'); expect(result).toBe(true); }); it('should setIfNotExists', async () => { (mockRedis.set as any).mockResolvedValue('OK'); const result = await cache.setIfNotExists('key', 'value', 1800); expect(mockRedis.set).toHaveBeenCalledWith('test:key', 'value', 'EX', 1800, 'NX'); expect(result).toBe(true); }); it('should replace existing value', async () => { (mockRedis.get as any).mockResolvedValue(JSON.stringify({ old: 'data' })); (mockRedis.set as any).mockResolvedValue('OK'); const result = await cache.replace('key', { new: 'data' }, 3600); expect(result).toEqual({ old: 'data' }); }); it('should update field atomically', async () => { (mockRedis.eval as any).mockResolvedValue(['{"count": 5}', 3600]); const updater = (current: any) => ({ ...current, count: (current?.count || 0) + 1, }); const result = await cache.updateField('key', updater); expect(mockRedis.eval).toHaveBeenCalled(); expect(result).toEqual({ count: 5 }); }); it('should handle updateField with new key', async () => { (mockRedis.eval as any).mockResolvedValue([null, -2]); const updater = (current: any) => ({ value: 'new' }); await cache.updateField('key', updater); expect(mockRedis.setex).toHaveBeenCalled(); }); }); describe('event handlers', () => { it('should handle connection events for non-shared cache', () => { // Create non-shared cache mockRedis.on = mock((event: string, handler: Function) => { mockRedis._eventCallbacks[event] = handler; }); cache = new RedisCache({ shared: false, redisConfig: { host: 'localhost', port: 6379 }, logger: mockLogger, }); // Trigger events mockRedis._triggerEvent('connect'); expect(mockLogger.info).toHaveBeenCalledWith('Redis cache connected'); mockRedis._triggerEvent('ready'); expect(mockLogger.info).toHaveBeenCalledWith('Redis cache ready'); mockRedis._triggerEvent('error', new Error('Test error')); expect(mockLogger.error).toHaveBeenCalledWith( 'Redis cache connection error', expect.objectContaining({ error: 'Test error' }) ); mockRedis._triggerEvent('close'); expect(mockLogger.warn).toHaveBeenCalledWith('Redis cache connection closed'); mockRedis._triggerEvent('reconnecting'); expect(mockLogger.warn).toHaveBeenCalledWith('Redis cache reconnecting...'); }); }); });