fixed some lint issues

This commit is contained in:
Boki 2025-06-26 16:11:58 -04:00
parent 8680b6ec20
commit a700818a06
15 changed files with 1574 additions and 1319 deletions

View file

@ -1,18 +1,17 @@
import { CacheAdapter, NamespacedCache } from './namespaced-cache';
import { RedisCache } from './redis-cache';
import type { CacheProvider, ICache } from './types';
/**
* Factory class for creating cache instances
*/
export class CacheFactory {
static create(config: any, namespace: string): ICache {
static create(config: unknown, _namespace: string): ICache {
// For tests or when no config provided, return null cache
if (!config || !config.cache) {
if (!config || typeof config !== 'object' || !('cache' in config)) {
return createNullCache();
}
const provider = config.cache.provider || 'memory';
// const provider = config.cache.provider || 'memory';
// For now, always return null cache to keep tests simple
// In real implementation, this would create different cache types based on provider
@ -44,8 +43,12 @@ export function createNamespacedCache(
/**
* Type guard to check if cache is available
*/
export function isCacheAvailable(cache: any): cache is CacheProvider {
return cache !== null && cache !== undefined && typeof cache.get === 'function';
export function isCacheAvailable(cache: unknown): cache is CacheProvider {
return cache !== null &&
cache !== undefined &&
typeof cache === 'object' &&
'get' in cache &&
typeof (cache as CacheProvider).get === 'function';
}
/**

View file

@ -7,7 +7,7 @@ interface ConnectionConfig {
singleton?: boolean;
db?: number;
redisConfig: RedisConfig;
logger?: any;
logger?: unknown;
}
/**
@ -32,7 +32,9 @@ export class RedisConnectionManager {
if (singleton) {
const existing = RedisConnectionManager.connections.get(name);
if (existing) return existing;
if (existing) {
return existing;
}
}
const connection = this.createConnection(redisConfig);

View file

@ -14,7 +14,7 @@ export class NamespacedCache implements CacheProvider {
this.prefix = `${namespace}:`;
}
async get<T = any>(key: string): Promise<T | null> {
async get<T = unknown>(key: string): Promise<T | null> {
return this.cache.get(`${this.prefix}${key}`);
}
@ -77,7 +77,7 @@ export class NamespacedCache implements CacheProvider {
export class CacheAdapter implements CacheProvider {
constructor(private readonly cache: ICache) {}
async get<T = any>(key: string): Promise<T | null> {
async get<T = unknown>(key: string): Promise<T | null> {
return this.cache.get(key);
}

View file

@ -8,7 +8,7 @@ import type { CacheOptions, CacheProvider, CacheStats } from './types';
*/
export class RedisCache implements CacheProvider {
private redis: Redis;
private logger: any;
private logger: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } = console;
private defaultTTL: number;
private keyPrefix: string;
private stats: CacheStats = {
@ -63,7 +63,7 @@ export class RedisCache implements CacheProvider {
}
this.updateStats(true);
return JSON.parse(value);
} catch (error) {
} catch {
this.updateStats(false, true);
return null;
}
@ -135,7 +135,7 @@ export class RedisCache implements CacheProvider {
async exists(key: string): Promise<boolean> {
try {
return (await this.redis.exists(this.getKey(key))) === 1;
} catch (error) {
} catch {
this.updateStats(false, true);
return false;
}
@ -184,7 +184,7 @@ export class RedisCache implements CacheProvider {
});
return keys;
} catch (error) {
} catch {
this.updateStats(false, true);
return [];
}

View file

@ -113,7 +113,7 @@ export interface CacheOptions {
name?: string; // Name for connection identification
shared?: boolean; // Whether to use shared connection
redisConfig: RedisConfig;
logger?: any; // Optional logger instance
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void };
}
export interface CacheStats {

View file

@ -1,543 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import Redis from 'ioredis';
import { describe, it, expect } from 'bun:test';
import { RedisConnectionManager } from '../src/connection-manager';
import type { RedisConfig } from '../src/types';
// Mock ioredis
const mockRedisInstance = {
on: mock((event: string, callback: Function) => {
// Store callbacks for triggering events
mockRedisInstance._eventCallbacks[event] = callback;
}),
once: mock((event: string, callback: Function) => {
mockRedisInstance._onceCallbacks[event] = callback;
}),
ping: mock(async () => 'PONG'),
quit: mock(async () => 'OK'),
status: 'ready',
_eventCallbacks: {} as Record<string, Function>,
_onceCallbacks: {} as Record<string, Function>,
// Helper to trigger events
_triggerEvent(event: string, ...args: any[]) {
if (this._eventCallbacks[event]) {
this._eventCallbacks[event](...args);
}
if (this._onceCallbacks[event]) {
this._onceCallbacks[event](...args);
delete this._onceCallbacks[event];
}
}
};
mock.module('ioredis', () => ({
default: mock(() => {
// Create a new instance for each Redis connection with event handling methods
const instance = {
...mockRedisInstance,
_eventCallbacks: {},
_onceCallbacks: {},
on: function(event: string, callback: Function) {
this._eventCallbacks[event] = callback;
return this;
},
once: function(event: string, callback: Function) {
this._onceCallbacks[event] = callback;
return this;
},
_triggerEvent: function(event: string, ...args: any[]) {
if (this._eventCallbacks[event]) {
this._eventCallbacks[event](...args);
}
if (this._onceCallbacks[event]) {
this._onceCallbacks[event](...args);
delete this._onceCallbacks[event];
}
}
};
return instance;
})
}));
// Skip these tests when running all tests together
// Run them individually with: bun test libs/core/cache/test/connection-manager.test.ts
describe.skip('RedisConnectionManager', () => {
let manager: RedisConnectionManager;
const mockLogger = {
info: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
};
beforeEach(() => {
// Clear static state
(RedisConnectionManager as any).instance = undefined;
if ((RedisConnectionManager as any).sharedConnections) {
(RedisConnectionManager as any).sharedConnections.clear();
}
if ((RedisConnectionManager as any).readyConnections) {
(RedisConnectionManager as any).readyConnections.clear();
}
// Get new instance
manager = RedisConnectionManager.getInstance();
// Set mock logger on the instance
(manager as any).logger = mockLogger;
// Reset mocks
mockLogger.info.mockClear();
mockLogger.error.mockClear();
mockLogger.warn.mockClear();
mockLogger.debug.mockClear();
});
afterEach(async () => {
await manager.closeAllConnections();
});
describe('getInstance', () => {
it('should return singleton instance', () => {
describe('RedisConnectionManager', () => {
it('should be a singleton', () => {
const instance1 = RedisConnectionManager.getInstance();
const instance2 = RedisConnectionManager.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('getConnection', () => {
const baseConfig: RedisConfig = {
it('should create connections', () => {
const manager = RedisConnectionManager.getInstance();
const connection = manager.getConnection({
name: 'test',
redisConfig: {
host: 'localhost',
port: 6379,
};
it('should create unique connection when singleton is false', () => {
const connection1 = manager.getConnection({
name: 'test',
singleton: false,
redisConfig: baseConfig,
logger: mockLogger,
});
const connection2 = manager.getConnection({
name: 'test',
singleton: false,
redisConfig: baseConfig,
logger: mockLogger,
});
expect(connection1).not.toBe(connection2);
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
});
it('should reuse shared connection when singleton is true', () => {
const connection1 = manager.getConnection({
name: 'shared-test',
singleton: true,
redisConfig: baseConfig,
logger: mockLogger,
});
const connection2 = manager.getConnection({
name: 'shared-test',
singleton: true,
redisConfig: baseConfig,
logger: mockLogger,
});
expect(connection1).toBe(connection2);
expect(mockLogger.info).toHaveBeenCalledWith('Created shared Redis connection: shared-test');
});
it('should apply custom db number', () => {
const connection = manager.getConnection({
name: 'db-test',
singleton: false,
db: 5,
redisConfig: baseConfig,
logger: mockLogger,
});
expect(connection).toBeDefined();
});
it('should handle TLS configuration', () => {
const tlsConfig: RedisConfig = {
...baseConfig,
tls: {
cert: 'cert-content',
key: 'key-content',
ca: 'ca-content',
rejectUnauthorized: false,
},
};
const connection = manager.getConnection({
name: 'tls-test',
singleton: false,
redisConfig: tlsConfig,
logger: mockLogger,
});
expect(connection).toBeDefined();
expect(connection.options.host).toBe('localhost');
expect(connection.options.port).toBe(6379);
});
it('should use provided logger', () => {
const customLogger = {
info: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
};
it('should reuse singleton connections', () => {
const manager = RedisConnectionManager.getInstance();
manager.getConnection({
name: 'logger-test',
singleton: false,
redisConfig: baseConfig,
logger: customLogger,
});
expect(customLogger.debug).toHaveBeenCalled();
});
});
describe('connection events', () => {
it('should handle connect event', () => {
const connection = manager.getConnection({
name: 'event-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Trigger connect event
(connection as any)._triggerEvent('connect');
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Redis connection established'));
});
it('should handle ready event', () => {
const connection = manager.getConnection({
name: 'ready-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Trigger ready event
(connection as any)._triggerEvent('ready');
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Redis connection ready'));
});
it('should handle error event', () => {
const connection = manager.getConnection({
name: 'error-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
const error = new Error('Connection failed');
(connection as any)._triggerEvent('error', error);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Redis connection error'),
error
);
});
it('should handle close event', () => {
const connection = manager.getConnection({
name: 'close-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
(connection as any)._triggerEvent('close');
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Redis connection closed'));
});
it('should handle reconnecting event', () => {
const connection = manager.getConnection({
name: 'reconnect-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
(connection as any)._triggerEvent('reconnecting');
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Redis reconnecting'));
});
});
describe('closeConnection', () => {
it('should close connection successfully', async () => {
const connection = manager.getConnection({
name: 'close-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
await manager.closeConnection(connection);
expect(connection.quit).toHaveBeenCalled();
});
it('should handle close errors gracefully', async () => {
const connection = manager.getConnection({
name: 'close-error-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Make quit throw an error
(connection.quit as any).mockImplementation(() => Promise.reject(new Error('Quit failed')));
await manager.closeConnection(connection);
expect(mockLogger.warn).toHaveBeenCalledWith(
'Error closing Redis connection:',
expect.any(Error)
);
});
});
describe('closeAllConnections', () => {
it('should close all unique connections', async () => {
const conn1 = manager.getConnection({
name: 'unique1',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
const conn2 = manager.getConnection({
name: 'unique2',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
await manager.closeAllConnections();
expect(conn1.quit).toHaveBeenCalled();
expect(conn2.quit).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith('All Redis connections closed');
});
it('should close shared connections', async () => {
const sharedConn = manager.getConnection({
name: 'shared',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
await manager.closeAllConnections();
expect(sharedConn.quit).toHaveBeenCalled();
expect(manager.getConnectionCount()).toEqual({ shared: 0, unique: 0 });
});
});
describe('getConnectionCount', () => {
it('should return correct connection counts', () => {
manager.getConnection({
name: 'unique1',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
manager.getConnection({
name: 'unique2',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
manager.getConnection({
name: 'shared1',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
});
const counts = manager.getConnectionCount();
expect(counts.unique).toBe(2);
expect(counts.shared).toBe(1);
});
});
describe('getConnectionNames', () => {
it('should return connection names', () => {
manager.getConnection({
name: 'test-unique',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
manager.getConnection({
name: 'test-shared',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
});
const names = manager.getConnectionNames();
expect(names.shared).toContain('test-shared');
expect(names.unique.length).toBe(1);
expect(names.unique[0]).toContain('test-unique');
});
});
describe('healthCheck', () => {
it('should report healthy connections', async () => {
manager.getConnection({
name: 'health-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
const health = await manager.healthCheck();
expect(health.healthy).toBe(true);
expect(Object.keys(health.details).length).toBeGreaterThan(0);
});
it('should report unhealthy connections', async () => {
const connection = manager.getConnection({
name: 'unhealthy-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
// Make ping fail
(connection.ping as any).mockImplementation(() => Promise.reject(new Error('Ping failed')));
const health = await manager.healthCheck();
expect(health.healthy).toBe(false);
expect(Object.values(health.details)).toContain(false);
});
});
describe('waitForAllConnections', () => {
it('should wait for connections to be ready', async () => {
const connection = manager.getConnection({
name: 'wait-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Connection is already ready
await RedisConnectionManager.waitForAllConnections(1000);
expect(mockLogger.info).toHaveBeenCalledWith('All Redis connections are ready');
});
it('should handle no connections', async () => {
await RedisConnectionManager.waitForAllConnections(1000);
expect(mockLogger.debug).toHaveBeenCalledWith('No Redis connections to wait for');
});
it('should timeout if connection not ready', async () => {
const connection = manager.getConnection({
name: 'timeout-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
// Make connection not ready
(connection as any).status = 'connecting';
await expect(RedisConnectionManager.waitForAllConnections(100)).rejects.toThrow(
'failed to be ready within 100ms'
);
});
it('should handle connection errors during wait', async () => {
const connection = manager.getConnection({
name: 'error-wait-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Make connection not ready
(connection as any).status = 'connecting';
// Trigger error after a delay
setTimeout(() => {
(connection as any)._triggerEvent('error', new Error('Connection failed'));
}, 50);
await expect(RedisConnectionManager.waitForAllConnections(1000)).rejects.toThrow(
'Connection failed'
);
});
});
describe('areAllConnectionsReady', () => {
it('should return false when no connections', () => {
expect(RedisConnectionManager.areAllConnectionsReady()).toBe(false);
});
it('should return true when all connections ready', async () => {
const connection = manager.getConnection({
name: 'ready-check-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
await RedisConnectionManager.waitForAllConnections(1000);
expect(RedisConnectionManager.areAllConnectionsReady()).toBe(true);
});
});
describe('edge cases', () => {
it('should handle concurrent access to shared connections', () => {
// Test that multiple requests for the same shared connection return the same instance
const conn1 = manager.getConnection({
name: 'shared-concurrent',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
redisConfig: {
host: 'localhost',
port: 6379,
},
});
const conn2 = manager.getConnection({
name: 'shared-concurrent',
name: 'shared',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
redisConfig: {
host: 'localhost',
port: 6379,
},
});
expect(conn1).toBe(conn2);
expect(manager.getConnectionCount().shared).toBe(1);
});
it('should apply all Redis options', () => {
const fullConfig: RedisConfig = {
it('should create separate non-singleton connections', () => {
const manager = RedisConnectionManager.getInstance();
const conn1 = manager.getConnection({
name: 'separate1',
singleton: false,
redisConfig: {
host: 'localhost',
port: 6379,
username: 'user',
password: 'pass',
db: 2,
maxRetriesPerRequest: 5,
retryDelayOnFailover: 200,
connectTimeout: 20000,
commandTimeout: 10000,
keepAlive: 5000,
};
},
});
const connection = manager.getConnection({
name: 'full-config-test',
const conn2 = manager.getConnection({
name: 'separate2',
singleton: false,
redisConfig: fullConfig,
redisConfig: {
host: 'localhost',
port: 6379,
},
});
expect(connection).toBeDefined();
});
expect(conn1).not.toBe(conn2);
});
it('should close all connections', async () => {
const manager = RedisConnectionManager.getInstance();
// Create a few connections
manager.getConnection({
name: 'close-test-1',
redisConfig: { host: 'localhost', port: 6379 },
});
manager.getConnection({
name: 'close-test-2',
redisConfig: { host: 'localhost', port: 6379 },
});
// Close all
await RedisConnectionManager.closeAll();
// Should not throw
expect(true).toBe(true);
});
});

View file

@ -0,0 +1,542 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import { RedisConnectionManager } from '../src/connection-manager';
import type { RedisConfig } from '../src/types';
// Mock ioredis
const mockRedisInstance = {
on: mock((_event: string, _callback: Function) => {
// Store callbacks for triggering events
mockRedisInstance._eventCallbacks[event] = callback;
}),
once: mock((event: string, callback: Function) => {
mockRedisInstance._onceCallbacks[event] = callback;
}),
ping: mock(async () => 'PONG'),
quit: mock(async () => 'OK'),
status: 'ready',
_eventCallbacks: {} as Record<string, Function>,
_onceCallbacks: {} as Record<string, Function>,
// Helper to trigger events
_triggerEvent(event: string, ...args: any[]) {
if (this._eventCallbacks[event]) {
this._eventCallbacks[event](...args);
}
if (this._onceCallbacks[event]) {
this._onceCallbacks[event](...args);
delete this._onceCallbacks[event];
}
}
};
mock.module('ioredis', () => ({
default: mock(() => {
// Create a new instance for each Redis connection with event handling methods
const instance = {
...mockRedisInstance,
_eventCallbacks: {},
_onceCallbacks: {},
on: function(event: string, callback: Function) {
this._eventCallbacks[event] = callback;
return this;
},
once: function(event: string, callback: Function) {
this._onceCallbacks[event] = callback;
return this;
},
_triggerEvent: function(event: string, ...args: any[]) {
if (this._eventCallbacks[event]) {
this._eventCallbacks[event](...args);
}
if (this._onceCallbacks[event]) {
this._onceCallbacks[event](...args);
delete this._onceCallbacks[event];
}
}
};
return instance;
})
}));
// Skip these tests when running all tests together
// Run them individually with: bun test libs/core/cache/test/connection-manager.test.ts
describe.skip('RedisConnectionManager', () => {
let manager: RedisConnectionManager;
const mockLogger = {
info: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
};
beforeEach(() => {
// Clear static state
(RedisConnectionManager as any).instance = undefined;
if ((RedisConnectionManager as any).sharedConnections) {
(RedisConnectionManager as any).sharedConnections.clear();
}
if ((RedisConnectionManager as any).readyConnections) {
(RedisConnectionManager as any).readyConnections.clear();
}
// Get new instance
manager = RedisConnectionManager.getInstance();
// Set mock logger on the instance
(manager as any).logger = mockLogger;
// Reset mocks
mockLogger.info.mockClear();
mockLogger.error.mockClear();
mockLogger.warn.mockClear();
mockLogger.debug.mockClear();
});
afterEach(async () => {
await manager.closeAllConnections();
});
describe('getInstance', () => {
it('should return singleton instance', () => {
const instance1 = RedisConnectionManager.getInstance();
const instance2 = RedisConnectionManager.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('getConnection', () => {
const baseConfig: RedisConfig = {
host: 'localhost',
port: 6379,
};
it('should create unique connection when singleton is false', () => {
const connection1 = manager.getConnection({
name: 'test',
singleton: false,
redisConfig: baseConfig,
logger: mockLogger,
});
const connection2 = manager.getConnection({
name: 'test',
singleton: false,
redisConfig: baseConfig,
logger: mockLogger,
});
expect(connection1).not.toBe(connection2);
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
});
it('should reuse shared connection when singleton is true', () => {
const connection1 = manager.getConnection({
name: 'shared-test',
singleton: true,
redisConfig: baseConfig,
logger: mockLogger,
});
const connection2 = manager.getConnection({
name: 'shared-test',
singleton: true,
redisConfig: baseConfig,
logger: mockLogger,
});
expect(connection1).toBe(connection2);
expect(mockLogger.info).toHaveBeenCalledWith('Created shared Redis connection: shared-test');
});
it('should apply custom db number', () => {
const connection = manager.getConnection({
name: 'db-test',
singleton: false,
db: 5,
redisConfig: baseConfig,
logger: mockLogger,
});
expect(connection).toBeDefined();
});
it('should handle TLS configuration', () => {
const tlsConfig: RedisConfig = {
...baseConfig,
tls: {
cert: 'cert-content',
key: 'key-content',
ca: 'ca-content',
rejectUnauthorized: false,
},
};
const connection = manager.getConnection({
name: 'tls-test',
singleton: false,
redisConfig: tlsConfig,
logger: mockLogger,
});
expect(connection).toBeDefined();
});
it('should use provided logger', () => {
const customLogger = {
info: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
};
manager.getConnection({
name: 'logger-test',
singleton: false,
redisConfig: baseConfig,
logger: customLogger,
});
expect(customLogger.debug).toHaveBeenCalled();
});
});
describe('connection events', () => {
it('should handle connect event', () => {
const connection = manager.getConnection({
name: 'event-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Trigger connect event
(connection as any)._triggerEvent('connect');
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Redis connection established'));
});
it('should handle ready event', () => {
const connection = manager.getConnection({
name: 'ready-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Trigger ready event
(connection as any)._triggerEvent('ready');
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Redis connection ready'));
});
it('should handle error event', () => {
const connection = manager.getConnection({
name: 'error-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
const error = new Error('Connection failed');
(connection as any)._triggerEvent('error', error);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Redis connection error'),
error
);
});
it('should handle close event', () => {
const connection = manager.getConnection({
name: 'close-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
(connection as any)._triggerEvent('close');
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Redis connection closed'));
});
it('should handle reconnecting event', () => {
const connection = manager.getConnection({
name: 'reconnect-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
(connection as any)._triggerEvent('reconnecting');
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Redis reconnecting'));
});
});
describe('closeConnection', () => {
it('should close connection successfully', async () => {
const connection = manager.getConnection({
name: 'close-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
await manager.closeConnection(connection);
expect(connection.quit).toHaveBeenCalled();
});
it('should handle close errors gracefully', async () => {
const connection = manager.getConnection({
name: 'close-error-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Make quit throw an error
(connection.quit as any).mockImplementation(() => Promise.reject(new Error('Quit failed')));
await manager.closeConnection(connection);
expect(mockLogger.warn).toHaveBeenCalledWith(
'Error closing Redis connection:',
expect.any(Error)
);
});
});
describe('closeAllConnections', () => {
it('should close all unique connections', async () => {
const conn1 = manager.getConnection({
name: 'unique1',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
const conn2 = manager.getConnection({
name: 'unique2',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
await manager.closeAllConnections();
expect(conn1.quit).toHaveBeenCalled();
expect(conn2.quit).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith('All Redis connections closed');
});
it('should close shared connections', async () => {
const sharedConn = manager.getConnection({
name: 'shared',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
await manager.closeAllConnections();
expect(sharedConn.quit).toHaveBeenCalled();
expect(manager.getConnectionCount()).toEqual({ shared: 0, unique: 0 });
});
});
describe('getConnectionCount', () => {
it('should return correct connection counts', () => {
manager.getConnection({
name: 'unique1',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
manager.getConnection({
name: 'unique2',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
manager.getConnection({
name: 'shared1',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
});
const counts = manager.getConnectionCount();
expect(counts.unique).toBe(2);
expect(counts.shared).toBe(1);
});
});
describe('getConnectionNames', () => {
it('should return connection names', () => {
manager.getConnection({
name: 'test-unique',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
manager.getConnection({
name: 'test-shared',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
});
const names = manager.getConnectionNames();
expect(names.shared).toContain('test-shared');
expect(names.unique.length).toBe(1);
expect(names.unique[0]).toContain('test-unique');
});
});
describe('healthCheck', () => {
it('should report healthy connections', async () => {
manager.getConnection({
name: 'health-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
const health = await manager.healthCheck();
expect(health.healthy).toBe(true);
expect(Object.keys(health.details).length).toBeGreaterThan(0);
});
it('should report unhealthy connections', async () => {
const connection = manager.getConnection({
name: 'unhealthy-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
// Make ping fail
(connection.ping as any).mockImplementation(() => Promise.reject(new Error('Ping failed')));
const health = await manager.healthCheck();
expect(health.healthy).toBe(false);
expect(Object.values(health.details)).toContain(false);
});
});
describe('waitForAllConnections', () => {
it('should wait for connections to be ready', async () => {
manager.getConnection({
name: 'wait-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Connection is already ready
await RedisConnectionManager.waitForAllConnections(1000);
expect(mockLogger.info).toHaveBeenCalledWith('All Redis connections are ready');
});
it('should handle no connections', async () => {
await RedisConnectionManager.waitForAllConnections(1000);
expect(mockLogger.debug).toHaveBeenCalledWith('No Redis connections to wait for');
});
it('should timeout if connection not ready', async () => {
const connection = manager.getConnection({
name: 'timeout-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
// Make connection not ready
(connection as any).status = 'connecting';
await expect(RedisConnectionManager.waitForAllConnections(100)).rejects.toThrow(
'failed to be ready within 100ms'
);
});
it('should handle connection errors during wait', async () => {
const connection = manager.getConnection({
name: 'error-wait-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// Make connection not ready
(connection as any).status = 'connecting';
// Trigger error after a delay
setTimeout(() => {
(connection as any)._triggerEvent('error', new Error('Connection failed'));
}, 50);
await expect(RedisConnectionManager.waitForAllConnections(1000)).rejects.toThrow(
'Connection failed'
);
});
});
describe('areAllConnectionsReady', () => {
it('should return false when no connections', () => {
expect(RedisConnectionManager.areAllConnectionsReady()).toBe(false);
});
it('should return true when all connections ready', async () => {
manager.getConnection({
name: 'ready-check-test',
singleton: false,
redisConfig: { host: 'localhost', port: 6379 },
});
await RedisConnectionManager.waitForAllConnections(1000);
expect(RedisConnectionManager.areAllConnectionsReady()).toBe(true);
});
});
describe('edge cases', () => {
it('should handle concurrent access to shared connections', () => {
// Test that multiple requests for the same shared connection return the same instance
const conn1 = manager.getConnection({
name: 'shared-concurrent',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
});
const conn2 = manager.getConnection({
name: 'shared-concurrent',
singleton: true,
redisConfig: { host: 'localhost', port: 6379 },
});
expect(conn1).toBe(conn2);
expect(manager.getConnectionCount().shared).toBe(1);
});
it('should apply all Redis options', () => {
const fullConfig: RedisConfig = {
host: 'localhost',
port: 6379,
username: 'user',
password: 'pass',
db: 2,
maxRetriesPerRequest: 5,
retryDelayOnFailover: 200,
connectTimeout: 20000,
commandTimeout: 10000,
keepAlive: 5000,
};
const connection = manager.getConnection({
name: 'full-config-test',
singleton: false,
redisConfig: fullConfig,
});
expect(connection).toBeDefined();
});
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach } from 'bun:test';
import { RedisCache } from '../src/redis-cache';
import type { CacheOptions } from '../src/types';
describe('RedisCache Simple', () => {
let cache: RedisCache;
beforeEach(() => {
const options: CacheOptions = {
keyPrefix: 'test:',
ttl: 3600,
redisConfig: { host: 'localhost', port: 6379 },
};
cache = new RedisCache(options);
});
describe('Core functionality', () => {
it('should create cache instance', () => {
expect(cache).toBeDefined();
expect(cache.isReady).toBeDefined();
expect(cache.get).toBeDefined();
expect(cache.set).toBeDefined();
});
it('should have stats tracking', () => {
const stats = cache.getStats();
expect(stats).toMatchObject({
hits: 0,
misses: 0,
errors: 0,
hitRate: 0,
total: 0,
});
expect(stats.uptime).toBeGreaterThanOrEqual(0);
});
});
});

View file

@ -1,697 +1,210 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import Redis from 'ioredis';
import { describe, it, expect, beforeEach } from 'bun:test';
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<string, Function>,
_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<typeof createMockRedis>;
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 = {
keyPrefix: 'test:',
ttl: 3600,
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),
describe('Core functionality', () => {
it('should create cache instance', () => {
expect(cache).toBeDefined();
expect(cache.isReady).toBeDefined();
expect(cache.get).toBeDefined();
expect(cache.set).toBeDefined();
});
it('should have stats tracking', () => {
const stats = cache.getStats();
expect(stats).toMatchObject({
hits: 0,
misses: 0,
errors: 0,
hitRate: 0,
total: 0,
});
expect(stats.uptime).toBeGreaterThanOrEqual(0);
});
});
it('should use custom name and prefix', () => {
const options: CacheOptions = {
name: 'MyCache',
keyPrefix: 'custom:',
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
};
describe('Basic operations', () => {
it('should handle get/set operations', async () => {
const key = 'test-key';
const value = { foo: 'bar' };
cache = new RedisCache(options);
// Should return null for non-existent key
const miss = await cache.get(key);
expect(miss).toBeNull();
expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({
name: 'MyCache-SERVICE',
singleton: true,
redisConfig: options.redisConfig,
logger: mockLogger,
});
});
// Should set and retrieve value
await cache.set(key, value);
const retrieved = await cache.get(key);
expect(retrieved).toEqual(value);
it('should handle non-shared connections', () => {
const options: CacheOptions = {
shared: false,
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
};
cache = new RedisCache(options);
// Should create a new connection for non-shared
expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({
name: 'CACHE-SERVICE',
singleton: false,
redisConfig: options.redisConfig,
logger: mockLogger,
});
});
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<string>('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 },
});
// Should delete key
await cache.del(key);
const deleted = await cache.get(key);
expect(deleted).toBeNull();
});
it('should check key existence', async () => {
(mockRedis.exists as any).mockResolvedValue(1);
const key = 'existence-test';
const result = await cache.exists('mykey');
expect(await cache.exists(key)).toBe(false);
expect(mockRedis.exists).toHaveBeenCalledWith('test:mykey');
expect(result).toBe(true);
await cache.set(key, 'value');
expect(await cache.exists(key)).toBe(true);
await cache.del(key);
expect(await cache.exists(key)).toBe(false);
});
it('should return false for non-existent key', async () => {
(mockRedis.exists as any).mockResolvedValue(0);
it('should handle TTL in set operations', async () => {
const key = 'ttl-test';
const value = 'test-value';
const result = await cache.exists('nonexistent');
// Set with custom TTL as number
await cache.set(key, value, 1);
expect(await cache.get(key)).toBe(value);
expect(result).toBe(false);
// Set with custom TTL in options
await cache.set(key, value, { ttl: 2 });
expect(await cache.get(key)).toBe(value);
});
});
describe('clear', () => {
beforeEach(() => {
cache = new RedisCache({
keyPrefix: 'test:',
describe('Advanced set options', () => {
it('should handle onlyIfExists option', async () => {
const key = 'conditional-test';
const value1 = 'value1';
const value2 = 'value2';
// Should not set if key doesn't exist
await cache.set(key, value1, { onlyIfExists: true });
expect(await cache.get(key)).toBeNull();
// Create the key
await cache.set(key, value1);
// Should update if key exists
await cache.set(key, value2, { onlyIfExists: true });
expect(await cache.get(key)).toBe(value2);
});
it('should handle onlyIfNotExists option', async () => {
const key = 'nx-test';
const value1 = 'value1';
const value2 = 'value2';
// Should set if key doesn't exist
await cache.set(key, value1, { onlyIfNotExists: true });
expect(await cache.get(key)).toBe(value1);
// Should not update if key exists
await cache.set(key, value2, { onlyIfNotExists: true });
expect(await cache.get(key)).toBe(value1);
});
it('should handle preserveTTL option', async () => {
const key = 'preserve-ttl-test';
const value1 = 'value1';
const value2 = 'value2';
// Set with short TTL
await cache.set(key, value1, 10);
// Update preserving TTL
await cache.set(key, value2, { preserveTTL: true });
expect(await cache.get(key)).toBe(value2);
});
it('should handle getOldValue option', async () => {
const key = 'old-value-test';
const value1 = 'value1';
const value2 = 'value2';
// Should return null when no old value
const oldValue1 = await cache.set(key, value1, { getOldValue: true });
expect(oldValue1).toBeNull();
// Should return old value
const oldValue2 = await cache.set(key, value2, { getOldValue: true });
expect(oldValue2).toBe(value1);
});
});
describe('Error handling', () => {
it('should handle errors gracefully in get', async () => {
// Force an error by using invalid JSON
const badCache = new RedisCache({
keyPrefix: 'bad:',
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
// This would normally throw but should return null
const result = await badCache.get('non-existent');
expect(result).toBeNull();
// Check stats updated
const stats = badCache.getStats();
expect(stats.misses).toBe(1);
});
});
it('should clear all prefixed keys', async () => {
const keys = ['test:key1', 'test:key2', 'test:key3'];
(mockRedis.keys as any).mockResolvedValue(keys);
describe('Pattern operations', () => {
it('should find keys by pattern', async () => {
// Clear first to ensure clean state
await cache.clear();
await cache.set('user:1', { id: 1 });
await cache.set('user:2', { id: 2 });
await cache.set('post:1', { id: 1 });
const userKeys = await cache.keys('user:*');
expect(userKeys).toHaveLength(2);
expect(userKeys).toContain('user:1');
expect(userKeys).toContain('user:2');
const allKeys = await cache.keys('*');
expect(allKeys.length).toBeGreaterThanOrEqual(3);
expect(allKeys).toContain('user:1');
expect(allKeys).toContain('user:2');
expect(allKeys).toContain('post:1');
});
it('should clear all keys with prefix', async () => {
await cache.set('key1', 'value1');
await cache.set('key2', 'value2');
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();
const keys = await cache.keys('*');
expect(keys).toHaveLength(0);
});
});
describe('getRaw', () => {
beforeEach(() => {
cache = new RedisCache({
keyPrefix: 'test:',
redisConfig: { host: 'localhost', port: 6379 },
logger: mockLogger,
});
describe('Health checks', () => {
it('should check health', async () => {
const healthy = await cache.health();
expect(healthy).toBe(true);
});
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 check if ready', () => {
// May not be ready immediately
const ready = cache.isReady();
expect(typeof ready).toBe('boolean');
});
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';
it('should wait for ready', async () => {
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...');
});
});
});

View file

@ -0,0 +1,695 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import { RedisCache } from '../src/redis-cache';
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<string, Function>,
_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<typeof createMockRedis>;
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,
};
cache = new RedisCache(options);
// Should create a new connection for non-shared
expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({
name: 'CACHE-SERVICE',
singleton: false,
redisConfig: options.redisConfig,
logger: mockLogger,
});
});
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<string>('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: unknown) => ({ 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...');
});
});
});

View file

@ -12,6 +12,8 @@ import type {
Environment,
} from './types';
export type { ConfigManagerOptions } from './types';
export class ConfigManager<T = Record<string, unknown>> {
private readonly logger = getLogger('config-manager');
private config: T | null = null;

View file

@ -1,7 +1,8 @@
import { ConfigManager } from './config-manager';
import { ConfigManager, type ConfigManagerOptions } from './config-manager';
// Export only what's actually used
export { ConfigManager } from './config-manager';
export type { ConfigManagerOptions } from './config-manager';
export { toUnifiedConfig } from './schemas/unified-app.schema';
// Export used types
@ -17,6 +18,6 @@ export {
} from './schemas';
// createAppConfig function for apps/stock
export function createAppConfig<T>(schema: any, options?: any): ConfigManager<T> {
export function createAppConfig<T>(schema: unknown, options?: ConfigManagerOptions): ConfigManager<T> {
return new ConfigManager<T>(options);
}

View file

@ -1,23 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { beforeEach, describe, expect, it } from 'bun:test';
import { z } from 'zod';
import {
baseAppSchema,
ConfigError,
ConfigManager,
ConfigValidationError,
createAppConfig,
getConfig,
getDatabaseConfig,
getLogConfig,
getQueueConfig,
getServiceConfig,
initializeAppConfig,
initializeServiceConfig,
isDevelopment,
isProduction,
isTest,
resetConfig,
} from '../src';
import { ConfigError, ConfigValidationError } from '../src/errors';
// Mock loader for testing
class MockLoader {
@ -172,21 +160,6 @@ describe('ConfigManager', () => {
expect(validated).toEqual({ name: 'test', port: 3000 });
});
it('should create typed getter', () => {
const schema = z.object({
name: z.string(),
port: z.number(),
});
const mockManager = new ConfigManager({
loaders: [new MockLoader({ name: 'test', port: 3000 })],
});
mockManager.initialize();
const getTypedConfig = mockManager.createTypedGetter(schema);
const config = getTypedConfig();
expect(config).toEqual({ name: 'test', port: 3000 });
});
it('should add environment if not present', () => {
const mockManager = new ConfigManager({
@ -199,68 +172,6 @@ describe('ConfigManager', () => {
});
});
describe('Config Service Functions', () => {
beforeEach(() => {
resetConfig();
});
it('should throw when getting config before initialization', () => {
expect(() => getConfig()).toThrow(ConfigError);
});
it('should validate config with schema', () => {
// Test that a valid config passes schema validation
const mockConfig = {
name: 'test-app',
version: '1.0.0',
environment: 'test' as const,
service: {
name: 'test-service',
baseUrl: 'http://localhost:3000',
port: 3000,
},
database: {
mongodb: {
uri: 'mongodb://localhost',
database: 'test-db',
},
postgres: {
host: 'localhost',
port: 5432,
database: 'test-db',
user: 'test-user',
password: 'test-pass',
},
questdb: {
host: 'localhost',
httpPort: 9000,
},
},
log: {
level: 'info' as const,
pretty: true,
},
queue: {
redis: { host: 'localhost', port: 6379 },
},
};
const manager = new ConfigManager({
loaders: [new MockLoader(mockConfig)],
});
// Should not throw when initializing with valid config
expect(() => manager.initialize(baseAppSchema)).not.toThrow();
// Verify key properties exist
const config = manager.get();
expect(config.name).toBe('test-app');
expect(config.version).toBe('1.0.0');
expect(config.environment).toBe('test');
expect(config.service.name).toBe('test-service');
expect(config.database.mongodb.uri).toBe('mongodb://localhost');
});
});
describe('Config Builders', () => {
it('should create app config with schema', () => {
@ -282,23 +193,16 @@ describe('Config Builders', () => {
version: z.number(),
});
const config = initializeAppConfig(schema, {
const configManager = createAppConfig(schema, {
loaders: [new MockLoader({ app: 'myapp', version: 1 })],
});
const config = configManager.initialize(schema);
expect(config).toEqual({ app: 'myapp', version: 1 });
});
});
describe('Environment Helpers', () => {
beforeEach(() => {
resetConfig();
});
afterEach(() => {
resetConfig();
});
describe('Environment Detection', () => {
it('should detect environments correctly in ConfigManager', () => {
// Test with different environments using mock configs
const envConfigs = [{ env: 'development' }, { env: 'production' }, { env: 'test' }];

View file

@ -339,9 +339,9 @@ describe.skip('ServiceApplication', () => {
const containerWithJobs = {
resolve: mock((name: string) => {
if (name === 'serviceContainer') return { test: 'container' };
if (name === 'handlerRegistry') return mockHandlerRegistry;
if (name === 'queueManager') return mockQueueManager;
if (name === 'serviceContainer') {return { test: 'container' };}
if (name === 'handlerRegistry') {return mockHandlerRegistry;}
if (name === 'queueManager') {return mockQueueManager;}
return null;
}),
};
@ -451,18 +451,18 @@ describe.skip('ServiceApplication', () => {
const mockContainer = {
resolve: mock((name: string) => {
if (name === 'serviceContainer') return { test: 'container' };
if (name === 'handlerRegistry') return {
if (name === 'serviceContainer') {return { test: 'container' };}
if (name === 'handlerRegistry') {return {
getAllHandlersWithSchedule: () => new Map(),
getHandlerNames: () => [],
};
if (name === 'queueManager') return {
};}
if (name === 'queueManager') {return {
shutdown: mock(() => Promise.resolve()),
startAllWorkers: mock(() => {}),
};
if (name === 'mongoClient') return { disconnect: mock(() => Promise.resolve()) };
if (name === 'postgresClient') return { disconnect: mock(() => Promise.resolve()) };
if (name === 'questdbClient') return { disconnect: mock(() => Promise.resolve()) };
};}
if (name === 'mongoClient') {return { disconnect: mock(() => Promise.resolve()) };}
if (name === 'postgresClient') {return { disconnect: mock(() => Promise.resolve()) };}
if (name === 'questdbClient') {return { disconnect: mock(() => Promise.resolve()) };}
return null;
}),
};

View file

@ -218,7 +218,12 @@ export class QueueManager {
keyPrefix: `batch:${queueName}:`,
ttl: 86400, // 24 hours default
enableMetrics: true,
logger: this.logger,
logger: {
info: (...args: unknown[]) => this.logger.info(String(args[0]), args[1] as Record<string, unknown>),
error: (...args: unknown[]) => this.logger.error(String(args[0]), args[1] as Record<string, unknown>),
warn: (...args: unknown[]) => this.logger.warn(String(args[0]), args[1] as Record<string, unknown>),
debug: (...args: unknown[]) => this.logger.debug(String(args[0]), args[1] as Record<string, unknown>),
},
});
this.caches.set(queueName, cacheProvider);
this.logger.trace('Cache created for queue', { queueName });