/**
* Unit tests for Connection Pool
*
* Tests health monitoring, graceful shutdown, and statistics tracking.
* Uses manually constructed mock to test behavior without a real database.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PoolError } from '../../types/index.js';
// Create mock functions that we can reference
const mockClientQuery = vi.fn();
const mockClientRelease = vi.fn();
const mockPoolConnect = vi.fn();
const mockPoolQuery = vi.fn();
const mockPoolEnd = vi.fn();
const mockPoolOn = vi.fn();
// Track pool counts
let mockTotalCount = 5;
let mockIdleCount = 3;
let mockWaitingCount = 0;
// Mock pg module before importing ConnectionPool
vi.mock('pg', () => {
const MockPool = function () {
return {
connect: mockPoolConnect,
query: mockPoolQuery,
end: mockPoolEnd,
on: mockPoolOn,
get totalCount() { return mockTotalCount; },
get idleCount() { return mockIdleCount; },
get waitingCount() { return mockWaitingCount; }
};
};
return {
default: { Pool: MockPool }
};
});
// Mock the logger to avoid console output
vi.mock('../../utils/logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}));
// Import after mocking
import { ConnectionPool } from '../ConnectionPool.js';
describe('ConnectionPool', () => {
let pool: ConnectionPool;
beforeEach(() => {
vi.clearAllMocks();
// Reset mock state
mockTotalCount = 5;
mockIdleCount = 3;
mockWaitingCount = 0;
// Setup default mock implementations
mockClientQuery.mockResolvedValue({ rows: [{ version: 'PostgreSQL 16.0' }] });
mockClientRelease.mockReturnValue(undefined);
mockPoolConnect.mockResolvedValue({
query: mockClientQuery,
release: mockClientRelease
});
mockPoolQuery.mockResolvedValue({ rows: [], rowCount: 0 });
mockPoolEnd.mockResolvedValue(undefined);
pool = new ConnectionPool({
host: 'localhost',
port: 5432,
user: 'test',
password: 'test',
database: 'testdb'
});
});
describe('Initialization', () => {
it('should initialize successfully', async () => {
await pool.initialize();
expect(pool.isInitialized()).toBe(true);
});
it('should not reinitialize if already initialized', async () => {
await pool.initialize();
await pool.initialize(); // Should warn but not throw
// Connect should not be called again (only once per init)
expect(pool.isInitialized()).toBe(true);
});
it('should test connection on initialization', async () => {
await pool.initialize();
// Should have connected and run a query
expect(mockPoolConnect).toHaveBeenCalled();
expect(mockClientQuery).toHaveBeenCalled();
});
});
describe('Health Monitoring', () => {
it('should report unhealthy when not initialized', async () => {
const health = await pool.checkHealth();
expect(health.connected).toBe(false);
expect(health.error).toBe('Pool not initialized');
});
it('should report healthy after initialization', async () => {
await pool.initialize();
// Mock successful health check query
mockPoolQuery.mockResolvedValueOnce({
rows: [{ version: 'PostgreSQL 16.0', current_database: 'testdb' }]
});
const health = await pool.checkHealth();
expect(health.connected).toBe(true);
expect(health.latencyMs).toBeDefined();
});
it('should include pool stats in health response', async () => {
await pool.initialize();
mockPoolQuery.mockResolvedValueOnce({
rows: [{ version: 'PostgreSQL 16.0', current_database: 'testdb' }]
});
const health = await pool.checkHealth();
expect(health.poolStats).toBeDefined();
expect(typeof health.poolStats?.total).toBe('number');
expect(typeof health.poolStats?.idle).toBe('number');
});
it('should report unhealthy on query failure', async () => {
await pool.initialize();
mockPoolQuery.mockRejectedValueOnce(new Error('Connection refused'));
const health = await pool.checkHealth();
expect(health.connected).toBe(false);
expect(health.error).toContain('Connection refused');
});
it('should measure latency for health checks', async () => {
await pool.initialize();
// Add artificial delay to query
mockPoolQuery.mockImplementationOnce(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return { rows: [{ version: 'PostgreSQL 16.0', current_database: 'testdb' }] };
});
const health = await pool.checkHealth();
expect(health.latencyMs).toBeGreaterThanOrEqual(0);
});
});
describe('Statistics Tracking', () => {
it('should track total query count', async () => {
await pool.initialize();
const initialStats = pool.getStats();
const initialQueries = initialStats.totalQueries;
await pool.query('SELECT 1');
await pool.query('SELECT 2');
const updatedStats = pool.getStats();
expect(updatedStats.totalQueries).toBe(initialQueries + 2);
});
it('should return pool stats snapshot', async () => {
await pool.initialize();
const stats = pool.getStats();
expect(stats).toHaveProperty('total');
expect(stats).toHaveProperty('active');
expect(stats).toHaveProperty('idle');
expect(stats).toHaveProperty('waiting');
expect(stats).toHaveProperty('totalQueries');
});
it('should sync stats from pg pool', async () => {
await pool.initialize();
// Set mock pg pool counters
mockTotalCount = 10;
mockIdleCount = 4;
mockWaitingCount = 2;
const stats = pool.getStats();
expect(stats.total).toBe(10);
expect(stats.idle).toBe(4);
expect(stats.waiting).toBe(2);
expect(stats.active).toBe(6); // total - idle
});
});
describe('Graceful Shutdown', () => {
it('should set shutting down state', async () => {
await pool.initialize();
expect(pool.isClosing()).toBe(false);
await pool.shutdown();
expect(pool.isClosing()).toBe(true);
});
it('should reject new connections after shutdown', async () => {
await pool.initialize();
await pool.shutdown();
// After shutdown, pool is null so 'not initialized' is the correct error
await expect(pool.getConnection()).rejects.toThrow(PoolError);
await expect(pool.getConnection()).rejects.toThrow('not initialized');
});
it('should report unhealthy during shutdown', async () => {
await pool.initialize();
await pool.shutdown();
const health = await pool.checkHealth();
expect(health.connected).toBe(false);
expect(health.error).toContain('shutting down');
});
it('should call pool.end() on shutdown', async () => {
await pool.initialize();
await pool.shutdown();
expect(mockPoolEnd).toHaveBeenCalled();
});
it('should handle shutdown when not initialized', async () => {
// Should not throw
await expect(pool.shutdown()).resolves.toBeUndefined();
});
});
describe('Connection Management', () => {
it('should throw when getting connection from uninitialized pool', async () => {
await expect(pool.getConnection()).rejects.toThrow(PoolError);
await expect(pool.getConnection()).rejects.toThrow('not initialized');
});
it('should throw when querying uninitialized pool', async () => {
await expect(pool.query('SELECT 1')).rejects.toThrow(PoolError);
});
it('should release connections properly', async () => {
await pool.initialize();
const client = await pool.getConnection();
pool.releaseConnection(client);
expect(mockClientRelease).toHaveBeenCalled();
});
});
describe('Event Handlers', () => {
it('should register pool event handlers', async () => {
await pool.initialize();
// Verify event handlers were registered
const onCalls = mockPoolOn.mock.calls;
const registeredEvents = onCalls.map(call => call[0]);
expect(registeredEvents).toContain('connect');
expect(registeredEvents).toContain('acquire');
expect(registeredEvents).toContain('release');
expect(registeredEvents).toContain('remove');
expect(registeredEvents).toContain('error');
});
it('should trigger connect handler on new connection', async () => {
await pool.initialize();
// Find the connect handler and invoke it
const connectHandler = mockPoolOn.mock.calls.find(call => call[0] === 'connect');
expect(connectHandler).toBeDefined();
if (connectHandler && typeof connectHandler[1] === 'function') {
connectHandler[1](); // Trigger connect event
// The stats.total should increment (handled internally)
}
});
it('should trigger error handler on pool error', async () => {
await pool.initialize();
// Find the error handler and invoke it
const errorHandler = mockPoolOn.mock.calls.find(call => call[0] === 'error');
expect(errorHandler).toBeDefined();
if (errorHandler && typeof errorHandler[1] === 'function') {
// Should not throw, just log
expect(() => errorHandler[1](new Error('Pool connection lost'))).not.toThrow();
}
});
it('should execute acquire event handler without error', async () => {
await pool.initialize();
// Find the acquire handler and invoke it
const acquireHandler = mockPoolOn.mock.calls.find(call => call[0] === 'acquire');
expect(acquireHandler).toBeDefined();
// Execute the handler - this covers lines 97-98
if (acquireHandler && typeof acquireHandler[1] === 'function') {
expect(() => acquireHandler[1]()).not.toThrow();
}
});
it('should execute release event handler without error', async () => {
await pool.initialize();
// Find the release handler and invoke it
const releaseHandler = mockPoolOn.mock.calls.find(call => call[0] === 'release');
expect(releaseHandler).toBeDefined();
// Execute the handler - this covers lines 102-103
if (releaseHandler && typeof releaseHandler[1] === 'function') {
expect(() => releaseHandler[1]()).not.toThrow();
}
});
it('should execute remove event handler without error', async () => {
await pool.initialize();
// Find the remove handler and invoke it
const removeHandler = mockPoolOn.mock.calls.find(call => call[0] === 'remove');
expect(removeHandler).toBeDefined();
// Execute the handler - this covers lines 107-108
if (removeHandler && typeof removeHandler[1] === 'function') {
expect(() => removeHandler[1]()).not.toThrow();
}
});
it('should throw PoolError when acquiring during shutdown', async () => {
await pool.initialize();
// Start shutdown but don't await to keep pool in shuttingDown state
const shutdownPromise = pool.shutdown();
// Pool is now shutting down but not yet null
// This should trigger line 150: throw new PoolError('Connection pool is shutting down')
await expect(pool.getConnection()).rejects.toThrow('shutting down');
await shutdownPromise;
});
});
describe('Initialization Errors', () => {
it('should throw ConnectionError when initial connection fails', async () => {
mockPoolConnect.mockRejectedValueOnce(new Error('Connection refused'));
await expect(pool.initialize()).rejects.toThrow('Failed to connect to PostgreSQL');
});
it('should handle non-Error exceptions during initialization', async () => {
mockPoolConnect.mockRejectedValueOnce('Unknown failure');
await expect(pool.initialize()).rejects.toThrow('Failed to connect to PostgreSQL');
});
it('should clean up on initialization failure', async () => {
mockPoolConnect.mockRejectedValueOnce(new Error('Auth failure'));
await expect(pool.initialize()).rejects.toThrow();
// Pool should not be initialized after failure
expect(pool.isInitialized()).toBe(false);
});
});
describe('Connection Acquire Errors', () => {
it('should throw PoolError when connection acquire fails', async () => {
await pool.initialize();
mockPoolConnect.mockRejectedValueOnce(new Error('No available connections'));
await expect(pool.getConnection()).rejects.toThrow('Failed to acquire connection');
});
it('should decrement waiting count on connection failure', async () => {
await pool.initialize();
mockPoolConnect.mockRejectedValueOnce(new Error('Timeout'));
// Track that waiting count is handled
try {
await pool.getConnection();
} catch {
// Expected to throw
}
// Stats should be consistent (waiting should not go negative)
const stats = pool.getStats();
expect(stats.waiting).toBeGreaterThanOrEqual(0);
});
});
describe('Query Error Handling', () => {
it('should log and re-throw query errors', async () => {
await pool.initialize();
const queryError = new Error('syntax error at or near "SELCT"');
mockPoolQuery.mockRejectedValueOnce(queryError);
await expect(pool.query('SELCT 1')).rejects.toThrow('syntax error');
});
it('should increment query count even on failures', async () => {
await pool.initialize();
const initialStats = pool.getStats();
const initialQueries = initialStats.totalQueries;
mockPoolQuery.mockRejectedValueOnce(new Error('Query failed'));
try {
await pool.query('INVALID SQL');
} catch {
// Expected
}
const updatedStats = pool.getStats();
expect(updatedStats.totalQueries).toBe(initialQueries + 1);
});
});
describe('Release Connection Errors', () => {
it('should handle release errors gracefully', async () => {
await pool.initialize();
const client = await pool.getConnection();
// Make release throw
mockClientRelease.mockImplementationOnce(() => {
throw new Error('Already released');
});
// Should not throw, just log warning
expect(() => pool.releaseConnection(client)).not.toThrow();
});
it('should handle non-Error release exceptions', async () => {
await pool.initialize();
const client = await pool.getConnection();
mockClientRelease.mockImplementationOnce(() => {
throw 'String error';
});
expect(() => pool.releaseConnection(client)).not.toThrow();
});
});
describe('Shutdown Error Handling', () => {
it('should throw error if pool.end() fails', async () => {
await pool.initialize();
mockPoolEnd.mockRejectedValueOnce(new Error('Connections still active'));
await expect(pool.shutdown()).rejects.toThrow('Connections still active');
});
});
describe('SSL Configuration', () => {
it('should enable SSL with rejectUnauthorized=false when ssl=true', async () => {
const sslPool = new ConnectionPool({
host: 'localhost',
port: 5432,
user: 'test',
password: 'test',
database: 'testdb',
ssl: true
});
await sslPool.initialize();
// Pool was created (ssl config is applied internally)
expect(sslPool.isInitialized()).toBe(true);
});
it('should accept custom SSL configuration object', async () => {
const sslPool = new ConnectionPool({
host: 'localhost',
port: 5432,
user: 'test',
password: 'test',
database: 'testdb',
ssl: { rejectUnauthorized: true, ca: 'cert-content' }
});
await sslPool.initialize();
expect(sslPool.isInitialized()).toBe(true);
});
});
describe('Statement Timeout Configuration', () => {
it('should apply statement timeout when specified', async () => {
const timeoutPool = new ConnectionPool({
host: 'localhost',
port: 5432,
user: 'test',
password: 'test',
database: 'testdb',
statementTimeout: 30000
});
await timeoutPool.initialize();
expect(timeoutPool.isInitialized()).toBe(true);
});
it('should not apply statement timeout when zero or undefined', async () => {
const noTimeoutPool = new ConnectionPool({
host: 'localhost',
port: 5432,
user: 'test',
password: 'test',
database: 'testdb',
statementTimeout: 0
});
await noTimeoutPool.initialize();
expect(noTimeoutPool.isInitialized()).toBe(true);
});
});
describe('Pool Configuration Options', () => {
it('should apply custom pool config', async () => {
const customPool = new ConnectionPool({
host: 'localhost',
port: 5432,
user: 'test',
password: 'test',
database: 'testdb',
pool: {
max: 20,
min: 5,
idleTimeoutMillis: 60000,
connectionTimeoutMillis: 5000,
allowExitOnIdle: false
},
applicationName: 'test-app'
});
await customPool.initialize();
expect(customPool.isInitialized()).toBe(true);
});
});
});