542 lines
No EOL
16 KiB
TypeScript
542 lines
No EOL
16 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
}); |