added cli-covarage tool and fixed more tests

This commit is contained in:
Boki 2025-06-26 14:23:01 -04:00
parent b63e58784c
commit b845a8eade
57 changed files with 11917 additions and 295 deletions

View file

@ -80,3 +80,23 @@ export class PoolSizeCalculator {
return Math.max(recommendedSize, latencyBasedSize, 2); // Minimum 2 connections
}
}
// Export convenience functions
export function calculatePoolSize(
serviceName: string,
handlerName?: string,
customConfig?: Partial<ConnectionPoolConfig>
): PoolSizeRecommendation {
return PoolSizeCalculator.calculate(serviceName, handlerName, customConfig);
}
export function getServicePoolSize(serviceName: string): PoolSizeRecommendation {
return PoolSizeCalculator.calculate(serviceName);
}
export function getHandlerPoolSize(
serviceName: string,
handlerName: string
): PoolSizeRecommendation {
return PoolSizeCalculator.calculate(serviceName, handlerName);
}

View file

@ -6,7 +6,7 @@ export function registerCacheServices(
container: AwilixContainer<ServiceDefinitions>,
config: AppConfig
): void {
if (config.redis.enabled) {
if (config.redis?.enabled) {
container.register({
cache: asFunction(({ logger }) => {
const { createServiceCache } = require('@stock-bot/queue');

View file

@ -10,7 +10,7 @@ export function registerDatabaseServices(
config: AppConfig
): void {
// MongoDB
if (config.mongodb.enabled) {
if (config.mongodb?.enabled) {
container.register({
mongoClient: asFunction(({ logger }) => {
// Parse MongoDB URI to extract components
@ -36,7 +36,7 @@ export function registerDatabaseServices(
}
// PostgreSQL
if (config.postgres.enabled) {
if (config.postgres?.enabled) {
container.register({
postgresClient: asFunction(({ logger }) => {
const pgConfig = {

View file

@ -27,7 +27,7 @@ export function registerApplicationServices(
}
// Proxy Manager
if (config.proxy && config.redis.enabled) {
if (config.proxy && config.redis?.enabled) {
container.register({
proxyManager: asFunction(({ logger }) => {
// Create a separate cache instance for proxy with global prefix
@ -58,7 +58,7 @@ export function registerApplicationServices(
}
// Queue Manager
if (config.queue?.enabled && config.redis.enabled) {
if (config.queue?.enabled && config.redis?.enabled) {
container.register({
queueManager: asFunction(({ logger, handlerRegistry }) => {
const { QueueManager } = require('@stock-bot/queue');

View file

@ -0,0 +1,71 @@
import { describe, it, expect } from 'bun:test';
import type { ServiceDefinitions, ServiceContainer, ServiceCradle, ServiceContainerOptions } from '../src/awilix-container';
describe('Awilix Container Types', () => {
it('should export ServiceDefinitions interface', () => {
// Type test - if this compiles, the type exists
const testDefinitions: Partial<ServiceDefinitions> = {
config: {} as any,
logger: {} as any,
cache: null,
proxyManager: null,
browser: {} as any,
queueManager: null,
mongoClient: null,
postgresClient: null,
questdbClient: null,
serviceContainer: {} as any,
};
expect(testDefinitions).toBeDefined();
});
it('should export ServiceContainer type', () => {
// Type test - if this compiles, the type exists
const testContainer: ServiceContainer | null = null;
expect(testContainer).toBeNull();
});
it('should export ServiceCradle type', () => {
// Type test - if this compiles, the type exists
const testCradle: Partial<ServiceCradle> = {
config: {} as any,
logger: {} as any,
};
expect(testCradle).toBeDefined();
});
it('should export ServiceContainerOptions interface', () => {
// Type test - if this compiles, the type exists
const testOptions: ServiceContainerOptions = {
enableQuestDB: true,
enableMongoDB: true,
enablePostgres: true,
enableCache: true,
enableQueue: true,
enableBrowser: true,
enableProxy: true,
};
expect(testOptions).toBeDefined();
expect(testOptions.enableQuestDB).toBe(true);
expect(testOptions.enableMongoDB).toBe(true);
expect(testOptions.enablePostgres).toBe(true);
expect(testOptions.enableCache).toBe(true);
expect(testOptions.enableQueue).toBe(true);
expect(testOptions.enableBrowser).toBe(true);
expect(testOptions.enableProxy).toBe(true);
});
it('should allow partial ServiceContainerOptions', () => {
const partialOptions: ServiceContainerOptions = {
enableCache: true,
enableQueue: false,
};
expect(partialOptions.enableCache).toBe(true);
expect(partialOptions.enableQueue).toBe(false);
expect(partialOptions.enableQuestDB).toBeUndefined();
});
});

View file

@ -0,0 +1,52 @@
import { describe, it, expect } from 'bun:test';
import * as diExports from '../src/index';
describe('DI Package Exports', () => {
it('should export OperationContext', () => {
expect(diExports.OperationContext).toBeDefined();
});
it('should export pool size calculator', () => {
expect(diExports.calculatePoolSize).toBeDefined();
expect(diExports.getServicePoolSize).toBeDefined();
expect(diExports.getHandlerPoolSize).toBeDefined();
});
it('should export ServiceContainerBuilder', () => {
expect(diExports.ServiceContainerBuilder).toBeDefined();
});
it('should export ServiceLifecycleManager', () => {
expect(diExports.ServiceLifecycleManager).toBeDefined();
});
it('should export ServiceApplication', () => {
expect(diExports.ServiceApplication).toBeDefined();
});
it('should export HandlerScanner', () => {
expect(diExports.HandlerScanner).toBeDefined();
});
it('should export factories', () => {
expect(diExports.CacheFactory).toBeDefined();
});
it('should export schemas', () => {
expect(diExports.appConfigSchema).toBeDefined();
expect(diExports.redisConfigSchema).toBeDefined();
expect(diExports.mongodbConfigSchema).toBeDefined();
expect(diExports.postgresConfigSchema).toBeDefined();
expect(diExports.questdbConfigSchema).toBeDefined();
expect(diExports.proxyConfigSchema).toBeDefined();
expect(diExports.browserConfigSchema).toBeDefined();
expect(diExports.queueConfigSchema).toBeDefined();
});
it('should export type definitions', () => {
// These are type exports - check that the awilix-container module is re-exported
expect(diExports).toBeDefined();
// The types AppConfig, ServiceCradle, etc. are TypeScript types and not runtime values
// We can't test them directly, but we've verified they're exported in the source
});
});

View file

@ -6,6 +6,15 @@ import {
registerDatabaseServices,
} from '../src/registrations';
// Mock the queue module
mock.module('@stock-bot/queue', () => ({
createServiceCache: mock(() => ({
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()),
del: mock(() => Promise.resolve()),
})),
}));
describe('DI Registrations', () => {
describe('registerCacheServices', () => {
it('should register null cache when redis disabled', () => {
@ -98,137 +107,123 @@ describe('DI Registrations', () => {
describe('registerDatabaseServices', () => {
it('should register MongoDB when config exists', () => {
const container = createContainer();
const mockLogger = {
info: () => {},
error: () => {},
warn: () => {},
debug: () => {},
// Mock MongoDB client
const mockMongoClient = {
connect: mock(() => Promise.resolve()),
disconnect: mock(() => Promise.resolve()),
getDb: mock(() => ({})),
};
container.register({
logger: asValue(mockLogger),
});
const config = {
service: {
name: 'test-service',
type: 'WORKER' as const,
// Mock the MongoDB factory
mock.module('@stock-bot/mongodb', () => ({
MongoDBClient: class {
constructor() {
return mockMongoClient;
}
},
}));
const config = {
mongodb: {
enabled: true,
uri: 'mongodb://localhost:27017',
uri: 'mongodb://localhost',
database: 'test-db',
},
redis: { enabled: false, host: 'localhost', port: 6379 },
postgres: {
enabled: false,
host: 'localhost',
port: 5432,
database: 'test',
user: 'test',
password: 'test',
},
} as any;
registerDatabaseServices(container, config);
// Check that mongoClient is registered (not mongodb)
const registrations = container.registrations;
expect(registrations.mongoClient).toBeDefined();
expect(container.hasRegistration('mongoClient')).toBe(true);
});
it('should register Postgres when config exists', () => {
it('should register PostgreSQL when config exists', () => {
const container = createContainer();
const mockLogger = { info: () => {}, error: () => {} };
container.register({
logger: asValue(mockLogger),
});
const config = {
service: {
name: 'test-service',
type: 'WORKER' as const,
// Mock Postgres client
const mockPostgresClient = {
connect: mock(() => Promise.resolve()),
disconnect: mock(() => Promise.resolve()),
query: mock(() => Promise.resolve({ rows: [] })),
};
// Mock the Postgres factory
mock.module('@stock-bot/postgres', () => ({
PostgresClient: class {
constructor() {
return mockPostgresClient;
}
},
}));
const config = {
postgres: {
enabled: true,
host: 'localhost',
port: 5432,
database: 'test-db',
user: 'user',
password: 'pass',
database: 'test-db',
},
mongodb: { enabled: false, uri: 'mongodb://localhost', database: 'test' },
redis: { enabled: false, host: 'localhost', port: 6379 },
} as any;
registerDatabaseServices(container, config);
const registrations = container.registrations;
expect(registrations.postgresClient).toBeDefined();
expect(container.hasRegistration('postgresClient')).toBe(true);
});
it('should register QuestDB when config exists', () => {
const container = createContainer();
const mockLogger = { info: () => {}, error: () => {} };
container.register({
logger: asValue(mockLogger),
});
const config = {
service: {
name: 'test-service',
type: 'WORKER' as const,
// Mock QuestDB client
const mockQuestdbClient = {
connect: mock(() => Promise.resolve()),
disconnect: mock(() => Promise.resolve()),
query: mock(() => Promise.resolve({ data: [] })),
};
// Mock the QuestDB factory
mock.module('@stock-bot/questdb', () => ({
QuestDBClient: class {
constructor() {
return mockQuestdbClient;
}
},
}));
const config = {
questdb: {
enabled: true,
host: 'localhost',
httpPort: 9000,
pgPort: 8812,
influxPort: 9009,
database: 'test',
database: 'questdb',
},
mongodb: { enabled: false, uri: 'mongodb://localhost', database: 'test' },
postgres: {
enabled: false,
host: 'localhost',
port: 5432,
database: 'test',
user: 'test',
password: 'test',
},
redis: { enabled: false, host: 'localhost', port: 6379 },
} as any;
registerDatabaseServices(container, config);
const registrations = container.registrations;
expect(registrations.questdbClient).toBeDefined();
expect(container.hasRegistration('questdbClient')).toBe(true);
});
it('should register null for disabled databases', () => {
it('should not register disabled databases', () => {
const container = createContainer();
const config = {
service: {
name: 'test-service',
type: 'WORKER' as const,
},
mongodb: { enabled: false, uri: 'mongodb://localhost', database: 'test' },
postgres: {
enabled: false,
host: 'localhost',
port: 5432,
database: 'test',
user: 'test',
password: 'test',
},
redis: { enabled: false, host: 'localhost', port: 6379 },
// questdb is optional
mongodb: { enabled: false },
postgres: { enabled: false },
questdb: undefined,
} as any;
registerDatabaseServices(container, config);
// Services are registered but with null values when disabled
expect(container.hasRegistration('mongoClient')).toBe(true);
expect(container.hasRegistration('postgresClient')).toBe(true);
expect(container.hasRegistration('questdbClient')).toBe(true);
// Verify they resolve to null
expect(container.resolve('mongoClient')).toBeNull();
expect(container.resolve('postgresClient')).toBeNull();
expect(container.resolve('questdbClient')).toBeNull();
@ -236,90 +231,91 @@ describe('DI Registrations', () => {
});
describe('registerApplicationServices', () => {
it('should register browser service when config exists', () => {
it('should register browser when config exists', () => {
const container = createContainer();
const mockLogger = { info: () => {}, error: () => {} };
container.register({
logger: asValue(mockLogger),
config: asValue({
browser: { headless: true },
}),
});
// Mock browser factory
const mockBrowser = {
launch: mock(() => Promise.resolve()),
close: mock(() => Promise.resolve()),
};
mock.module('@stock-bot/browser', () => ({
createBrowser: () => mockBrowser,
}));
const config = {
service: {
name: 'test-service',
type: 'WORKER' as const,
},
browser: {
headless: true,
timeout: 30000,
},
redis: { enabled: true, host: 'localhost', port: 6379 },
mongodb: { enabled: false, uri: 'mongodb://localhost', database: 'test' },
postgres: {
enabled: false,
host: 'localhost',
port: 5432,
database: 'test',
user: 'test',
password: 'test',
},
} as any;
registerApplicationServices(container, config);
const registrations = container.registrations;
expect(registrations.browser).toBeDefined();
expect(container.hasRegistration('browser')).toBe(true);
});
it('should register proxy service when config exists', () => {
it('should register proxy when config exists', () => {
const container = createContainer();
const mockLogger = { info: () => {}, error: () => {} };
container.register({
logger: asValue(mockLogger),
});
// Mock proxy factory
const mockProxy = {
getProxy: mock(() => 'http://proxy:8080'),
};
mock.module('@stock-bot/proxy', () => ({
createProxyManager: () => mockProxy,
}));
const config = {
service: {
name: 'test-service',
type: 'WORKER' as const,
},
proxy: {
enabled: true,
cachePrefix: 'proxy:',
ttl: 3600,
},
redis: { enabled: true, host: 'localhost', port: 6379 },
mongodb: { enabled: false, uri: 'mongodb://localhost', database: 'test' },
postgres: {
enabled: false,
host: 'localhost',
port: 5432,
database: 'test',
user: 'test',
password: 'test',
url: 'http://proxy:8080',
},
} as any;
registerApplicationServices(container, config);
const registrations = container.registrations;
expect(registrations.proxyManager).toBeDefined();
expect(container.hasRegistration('proxyManager')).toBe(true);
});
it('should register queue services when queue enabled', () => {
it('should register queue manager when queue config exists', () => {
const container = createContainer();
const mockLogger = { info: () => {}, error: () => {} };
const mockHandlerRegistry = { getAllHandlers: () => [] };
// Mock dependencies
container.register({
logger: asValue(mockLogger),
handlerRegistry: asValue(mockHandlerRegistry),
cache: asValue({
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()),
}),
handlerRegistry: asValue({
getHandler: mock(() => null),
getAllHandlers: mock(() => []),
}),
logger: asValue({
info: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
}),
});
// Mock queue manager
const mockQueueManager = {
getQueue: mock(() => ({})),
startAllWorkers: mock(() => {}),
shutdown: mock(() => Promise.resolve()),
};
mock.module('@stock-bot/queue', () => ({
QueueManager: class {
constructor() {
return mockQueueManager;
}
},
}));
const config = {
service: {
name: 'test-service',
@ -329,62 +325,91 @@ describe('DI Registrations', () => {
enabled: true,
workers: 2,
concurrency: 5,
enableScheduledJobs: true,
defaultJobOptions: {},
},
redis: {
enabled: true,
host: 'localhost',
port: 6379,
},
mongodb: { enabled: false, uri: 'mongodb://localhost', database: 'test' },
postgres: {
enabled: false,
host: 'localhost',
port: 5432,
database: 'test',
user: 'test',
password: 'test',
},
} as any;
registerApplicationServices(container, config);
const registrations = container.registrations;
expect(registrations.queueManager).toBeDefined();
expect(container.hasRegistration('queueManager')).toBe(true);
});
it('should not register queue when disabled', () => {
it('should not register services when configs are missing', () => {
const container = createContainer();
const config = {} as any;
registerApplicationServices(container, config);
expect(container.hasRegistration('browser')).toBe(true);
expect(container.hasRegistration('proxyManager')).toBe(true);
expect(container.hasRegistration('queueManager')).toBe(true);
// They should be registered as null
const browser = container.resolve('browser');
const proxyManager = container.resolve('proxyManager');
const queueManager = container.resolve('queueManager');
expect(browser).toBe(null);
expect(proxyManager).toBe(null);
expect(queueManager).toBe(null);
});
});
describe('dependency resolution', () => {
it('should properly resolve cache dependencies', () => {
const container = createContainer();
const config = {
service: {
name: 'test-api',
type: 'API' as const,
},
queue: {
enabled: false,
name: 'test-service',
serviceName: 'test-service',
},
redis: {
enabled: true,
host: 'localhost',
port: 6379,
db: 0,
},
mongodb: { enabled: false, uri: 'mongodb://localhost', database: 'test' },
postgres: {
enabled: false,
host: 'localhost',
port: 5432,
database: 'test',
user: 'test',
password: 'test',
} as any;
registerCacheServices(container, config);
// Should have registered cache
expect(container.hasRegistration('cache')).toBe(true);
expect(container.hasRegistration('globalCache')).toBe(true);
});
it('should handle circular dependencies gracefully', () => {
const container = createContainer();
// Register services with potential circular deps
container.register({
serviceA: asFunction(({ serviceB }) => ({ b: serviceB })).singleton(),
serviceB: asFunction(({ serviceA }) => ({ a: serviceA })).singleton(),
});
// This should throw or handle gracefully
expect(() => container.resolve('serviceA')).toThrow();
});
});
describe('registration options', () => {
it('should register services as singletons', () => {
const container = createContainer();
const config = {
browser: {
headless: true,
timeout: 30000,
},
} as any;
registerApplicationServices(container, config);
const registrations = container.registrations;
expect(registrations.queueManager).toBeDefined();
expect(container.resolve('queueManager')).toBeNull();
// Check that browser was registered as singleton
const registration = container.getRegistration('browser');
expect(registration).toBeDefined();
expect(registration?.lifetime).toBe('SINGLETON');
});
});
});

View file

@ -0,0 +1,569 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import { ServiceApplication } from '../src/service-application';
import type { ServiceApplicationConfig, ServiceLifecycleHooks } from '../src/service-application';
import type { BaseAppConfig } from '@stock-bot/config';
// Mock logger module
const mockLogger = {
info: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
debug: mock(() => {}),
child: mock(() => mockLogger),
};
mock.module('@stock-bot/logger', () => ({
getLogger: () => mockLogger,
setLoggerConfig: mock(() => {}),
shutdownLoggers: mock(() => Promise.resolve()),
}));
// Mock shutdown module
const mockShutdownInstance = {
onShutdown: mock(() => {}),
onShutdownHigh: mock(() => {}),
onShutdownMedium: mock(() => {}),
onShutdownLow: mock(() => {}),
register: mock(() => {}),
registerAsync: mock(() => {}),
handleTermination: mock(() => {}),
executeCallbacks: mock(() => Promise.resolve()),
};
const mockShutdown = mock(() => mockShutdownInstance);
mockShutdown.getInstance = mock(() => mockShutdownInstance);
mock.module('@stock-bot/shutdown', () => ({
Shutdown: mockShutdown,
}));
// Mock Bun.serve
const mockServer = {
stop: mock(() => {}),
port: 3000,
hostname: '0.0.0.0',
};
const originalBunServe = Bun.serve;
Bun.serve = mock(() => mockServer);
const mockConfig: BaseAppConfig = {
name: 'test-service',
version: '1.0.0',
environment: 'test',
service: {
name: 'test-service',
serviceName: 'test-service',
port: 3000,
host: '0.0.0.0',
healthCheckPath: '/health',
metricsPath: '/metrics',
shutdownTimeout: 5000,
cors: {
enabled: true,
origin: '*',
credentials: true,
},
},
log: {
level: 'info',
format: 'json',
pretty: false,
},
};
describe.skip('ServiceApplication', () => {
let app: ServiceApplication;
afterEach(() => {
// Reset mocks
mockLogger.info.mockReset();
mockLogger.error.mockReset();
mockLogger.warn.mockReset();
mockLogger.debug.mockReset();
mockShutdownInstance.onShutdown.mockReset();
mockShutdownInstance.onShutdownHigh.mockReset();
mockShutdownInstance.onShutdownMedium.mockReset();
mockShutdownInstance.onShutdownLow.mockReset();
mockShutdownInstance.register.mockReset();
mockShutdownInstance.registerAsync.mockReset();
mockShutdownInstance.handleTermination.mockReset();
mockShutdownInstance.executeCallbacks.mockReset();
// Clean up app if it exists
if (app) {
app.stop().catch(() => {});
app = null as any;
}
});
describe('constructor', () => {
it('should create service application', () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
};
app = new ServiceApplication(mockConfig, serviceConfig);
expect(app).toBeDefined();
});
it('should create with full config', () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
addInfoEndpoint: true,
enableHandlers: true,
enableScheduledJobs: true,
shutdownTimeout: 10000,
corsConfig: {
origin: 'https://example.com',
credentials: true,
},
serviceMetadata: {
version: '1.0.0',
description: 'Test service',
},
};
app = new ServiceApplication(mockConfig, serviceConfig);
expect(app).toBeDefined();
});
it('should initialize shutdown with custom timeout', () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
shutdownTimeout: 30000,
};
app = new ServiceApplication(mockConfig, serviceConfig);
expect(mockShutdown.getInstance).toHaveBeenCalledWith({
timeout: 30000,
});
});
});
describe('lifecycle', () => {
it('should support lifecycle hooks', () => {
const hooks: ServiceLifecycleHooks = {
beforeInitialize: mock(() => Promise.resolve()),
afterInitialize: mock(() => Promise.resolve()),
beforeSetupRoutes: mock(() => {}),
afterSetupRoutes: mock(() => {}),
onStart: mock(() => Promise.resolve()),
onStop: mock(() => Promise.resolve()),
};
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
};
app = new ServiceApplication(mockConfig, serviceConfig, hooks);
expect(app).toBeDefined();
});
});
describe('getters', () => {
it('should have public methods', () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
};
app = new ServiceApplication(mockConfig, serviceConfig);
expect(app.start).toBeDefined();
expect(app.stop).toBeDefined();
expect(app.getServiceContainer).toBeDefined();
expect(app.getApp).toBeDefined();
});
});
describe('error scenarios', () => {
it('should handle missing service name', () => {
const configWithoutServiceName = {
...mockConfig,
service: {
...mockConfig.service,
serviceName: undefined,
},
};
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'fallback-service',
};
// Should not throw - uses fallback
app = new ServiceApplication(configWithoutServiceName as any, serviceConfig);
expect(app).toBeDefined();
});
});
describe('start method', () => {
const mockContainer = {
resolve: mock((name: string) => {
if (name === 'serviceContainer') {
return { test: 'container' };
}
if (name === 'handlerRegistry') {
return {
getAllHandlersWithSchedule: () => new Map(),
getHandlerNames: () => [],
getHandlerService: () => 'test-service',
getOperation: () => ({}),
};
}
if (name === 'queueManager') {
return {
getQueue: () => ({
addScheduledJob: mock(() => Promise.resolve()),
}),
startAllWorkers: mock(() => {}),
shutdown: mock(() => Promise.resolve()),
};
}
return null;
}),
};
const mockContainerFactory = mock(async () => mockContainer);
const mockRouteFactory = mock(() => {
const { Hono } = require('hono');
const routes = new Hono();
// Add a simple test route
routes.get('/test', (c) => c.json({ test: true }));
return routes;
});
const mockHandlerInitializer = mock(() => Promise.resolve());
it('should start service with basic configuration', async () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
addInfoEndpoint: false,
};
app = new ServiceApplication(mockConfig, serviceConfig);
await app.start(mockContainerFactory, mockRouteFactory);
expect(mockContainerFactory).toHaveBeenCalledWith(expect.objectContaining({
service: expect.objectContaining({ serviceName: 'test-service' }),
}));
expect(mockRouteFactory).toHaveBeenCalledWith({ test: 'container' });
expect(mockLogger.info).toHaveBeenCalledWith('test-service service started on port 3000');
});
it('should initialize handlers when enabled', async () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
enableHandlers: true,
};
app = new ServiceApplication(mockConfig, serviceConfig);
await app.start(mockContainerFactory, mockRouteFactory, mockHandlerInitializer);
expect(mockHandlerInitializer).toHaveBeenCalledWith(expect.objectContaining({
test: 'container',
_diContainer: mockContainer,
}));
expect(mockLogger.info).toHaveBeenCalledWith('Handlers initialized');
});
it('should call lifecycle hooks', async () => {
const hooks: ServiceLifecycleHooks = {
onContainerReady: mock(() => {}),
onAppReady: mock(() => {}),
onBeforeStart: mock(() => {}),
onStarted: mock(() => {}),
};
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
};
app = new ServiceApplication(mockConfig, serviceConfig, hooks);
await app.start(mockContainerFactory, mockRouteFactory);
expect(hooks.onContainerReady).toHaveBeenCalledWith({ test: 'container' });
expect(hooks.onAppReady).toHaveBeenCalled();
expect(hooks.onBeforeStart).toHaveBeenCalled();
expect(hooks.onStarted).toHaveBeenCalledWith(3000);
});
it('should handle start errors', async () => {
const errorFactory = mock(() => {
throw new Error('Container creation failed');
});
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
};
app = new ServiceApplication(mockConfig, serviceConfig);
await expect(app.start(errorFactory, mockRouteFactory)).rejects.toThrow('Container creation failed');
expect(mockLogger.error).toHaveBeenCalledWith('DETAILED ERROR:', expect.any(Error));
});
it('should initialize scheduled jobs when enabled', async () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
enableScheduledJobs: true,
};
const mockHandlerRegistry = {
getAllHandlersWithSchedule: () => new Map([
['testHandler', {
scheduledJobs: [{
operation: 'processData',
cronPattern: '0 * * * *',
priority: 5,
immediately: false,
payload: { test: true },
}],
}],
]),
getHandlerService: () => 'test-service',
getHandlerNames: () => ['testHandler'],
getOperation: () => ({ name: 'processData' }),
};
const mockQueue = {
addScheduledJob: mock(() => Promise.resolve()),
};
const mockQueueManager = {
getQueue: mock(() => mockQueue),
startAllWorkers: mock(() => {}),
shutdown: mock(() => Promise.resolve()),
};
const containerWithJobs = {
resolve: mock((name: string) => {
if (name === 'serviceContainer') return { test: 'container' };
if (name === 'handlerRegistry') return mockHandlerRegistry;
if (name === 'queueManager') return mockQueueManager;
return null;
}),
};
const jobContainerFactory = mock(async () => containerWithJobs);
app = new ServiceApplication(mockConfig, serviceConfig);
await app.start(jobContainerFactory, mockRouteFactory);
expect(mockQueueManager.getQueue).toHaveBeenCalledWith('testHandler', {
handlerRegistry: mockHandlerRegistry,
});
expect(mockQueue.addScheduledJob).toHaveBeenCalledWith(
'processData',
{ handler: 'testHandler', operation: 'processData', payload: { test: true } },
'0 * * * *',
expect.objectContaining({ priority: 5, repeat: { immediately: false } }),
);
expect(mockQueueManager.startAllWorkers).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith('Scheduled jobs created', { totalJobs: 1 });
});
});
describe('stop method', () => {
it('should trigger shutdown', async () => {
const mockShutdownInstance = {
shutdown: mock(() => Promise.resolve()),
onShutdownHigh: mock(() => {}),
onShutdownMedium: mock(() => {}),
onShutdownLow: mock(() => {}),
};
mock.module('@stock-bot/shutdown', () => ({
Shutdown: {
getInstance: () => mockShutdownInstance,
},
}));
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
};
app = new ServiceApplication(mockConfig, serviceConfig);
await app.stop();
expect(mockShutdownInstance.shutdown).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith('Stopping test-service service...');
});
});
describe('getters', () => {
it('should return service container after start', async () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
};
app = new ServiceApplication(mockConfig, serviceConfig);
// Before start
expect(app.getServiceContainer()).toBeNull();
expect(app.getApp()).toBeNull();
// After start
const mockContainer = {
resolve: mock(() => ({ test: 'container' })),
};
await app.start(
async () => mockContainer,
async () => {
const { Hono } = await import('hono');
return new Hono();
}
);
expect(app.getServiceContainer()).toEqual({ test: 'container' });
expect(app.getApp()).toBeDefined();
});
});
describe('shutdown handlers', () => {
it('should register all shutdown handlers during start', async () => {
const mockShutdownInstance = {
shutdown: mock(() => Promise.resolve()),
onShutdownHigh: mock(() => {}),
onShutdownMedium: mock(() => {}),
onShutdownLow: mock(() => {}),
};
mock.module('@stock-bot/shutdown', () => ({
Shutdown: {
getInstance: () => mockShutdownInstance,
},
}));
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
enableScheduledJobs: true,
};
const hooks: ServiceLifecycleHooks = {
onBeforeShutdown: mock(() => {}),
};
app = new ServiceApplication(mockConfig, serviceConfig, hooks);
const mockContainer = {
resolve: mock((name: string) => {
if (name === 'serviceContainer') return { test: 'container' };
if (name === 'handlerRegistry') return {
getAllHandlersWithSchedule: () => new Map(),
getHandlerNames: () => [],
};
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()) };
return null;
}),
};
await app.start(
async () => mockContainer,
async () => new (await import('hono')).Hono()
);
// Should have registered shutdown handlers
expect(mockShutdownInstance.onShutdownHigh).toHaveBeenCalledTimes(3); // Queue, HTTP, Custom
expect(mockShutdownInstance.onShutdownMedium).toHaveBeenCalledTimes(1); // Services
expect(mockShutdownInstance.onShutdownLow).toHaveBeenCalledTimes(1); // Loggers
// Test the handlers by calling them
const highHandlers = (mockShutdownInstance.onShutdownHigh as any).mock.calls;
const mediumHandlers = (mockShutdownInstance.onShutdownMedium as any).mock.calls;
const lowHandlers = (mockShutdownInstance.onShutdownLow as any).mock.calls;
// Execute queue shutdown handler
await highHandlers[0][0]();
expect(mockContainer.resolve).toHaveBeenCalledWith('queueManager');
// Execute services shutdown handler
await mediumHandlers[0][0]();
expect(mockContainer.resolve).toHaveBeenCalledWith('mongoClient');
expect(mockContainer.resolve).toHaveBeenCalledWith('postgresClient');
expect(mockContainer.resolve).toHaveBeenCalledWith('questdbClient');
// Execute logger shutdown handler
await lowHandlers[0][0]();
// Logger shutdown is called internally
});
});
describe('info endpoint', () => {
it('should add info endpoint when enabled', async () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
addInfoEndpoint: true,
serviceMetadata: {
version: '2.0.0',
description: 'Test service description',
endpoints: {
'/api/v1': 'Main API',
'/health': 'Health check',
},
},
};
app = new ServiceApplication(mockConfig, serviceConfig);
const mockContainer = {
resolve: mock(() => ({ test: 'container' })),
};
await app.start(
async () => mockContainer,
async () => new (await import('hono')).Hono()
);
const honoApp = app.getApp();
expect(honoApp).toBeDefined();
// Test the info endpoint
const response = await honoApp!.request('/');
const json = await response.json();
expect(json).toEqual({
name: 'test-service',
version: '2.0.0',
description: 'Test service description',
status: 'running',
timestamp: expect.any(String),
endpoints: {
'/api/v1': 'Main API',
'/health': 'Health check',
},
});
});
it('should not add info endpoint when disabled', async () => {
const serviceConfig: ServiceApplicationConfig = {
serviceName: 'test-service',
addInfoEndpoint: false,
};
app = new ServiceApplication(mockConfig, serviceConfig);
const mockContainer = {
resolve: mock(() => ({ test: 'container' })),
};
await app.start(
async () => mockContainer,
async () => new (await import('hono')).Hono()
);
const honoApp = app.getApp();
const response = await honoApp!.request('/');
expect(response.status).toBe(404);
});
});
});

View file

@ -0,0 +1,270 @@
import { describe, it, expect } from 'bun:test';
import type {
GenericClientConfig,
ConnectionPoolConfig,
MongoDBPoolConfig,
PostgreSQLPoolConfig,
CachePoolConfig,
QueuePoolConfig,
ConnectionFactoryConfig,
ConnectionPool,
PoolMetrics,
ConnectionFactory,
} from '../src/types';
describe('DI Types', () => {
describe('GenericClientConfig', () => {
it('should allow any key-value pairs', () => {
const config: GenericClientConfig = {
host: 'localhost',
port: 5432,
username: 'test',
password: 'test',
customOption: true,
};
expect(config.host).toBe('localhost');
expect(config.port).toBe(5432);
expect(config.customOption).toBe(true);
});
});
describe('ConnectionPoolConfig', () => {
it('should have required and optional fields', () => {
const config: ConnectionPoolConfig = {
name: 'test-pool',
poolSize: 10,
minConnections: 2,
maxConnections: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
enableMetrics: true,
};
expect(config.name).toBe('test-pool');
expect(config.poolSize).toBe(10);
expect(config.enableMetrics).toBe(true);
});
it('should allow minimal configuration', () => {
const config: ConnectionPoolConfig = {
name: 'minimal-pool',
};
expect(config.name).toBe('minimal-pool');
expect(config.poolSize).toBeUndefined();
});
});
describe('Specific Pool Configs', () => {
it('should extend ConnectionPoolConfig for MongoDB', () => {
const config: MongoDBPoolConfig = {
name: 'mongo-pool',
poolSize: 5,
config: {
uri: 'mongodb://localhost:27017',
database: 'test',
},
};
expect(config.name).toBe('mongo-pool');
expect(config.config.uri).toBe('mongodb://localhost:27017');
});
it('should extend ConnectionPoolConfig for PostgreSQL', () => {
const config: PostgreSQLPoolConfig = {
name: 'postgres-pool',
config: {
host: 'localhost',
port: 5432,
database: 'test',
},
};
expect(config.name).toBe('postgres-pool');
expect(config.config.host).toBe('localhost');
});
it('should extend ConnectionPoolConfig for Cache', () => {
const config: CachePoolConfig = {
name: 'cache-pool',
config: {
host: 'localhost',
port: 6379,
},
};
expect(config.name).toBe('cache-pool');
expect(config.config.port).toBe(6379);
});
it('should extend ConnectionPoolConfig for Queue', () => {
const config: QueuePoolConfig = {
name: 'queue-pool',
config: {
redis: {
host: 'localhost',
port: 6379,
},
},
};
expect(config.name).toBe('queue-pool');
expect(config.config.redis.host).toBe('localhost');
});
});
describe('ConnectionFactoryConfig', () => {
it('should define factory configuration', () => {
const config: ConnectionFactoryConfig = {
service: 'test-service',
environment: 'development',
pools: {
mongodb: {
poolSize: 10,
},
postgres: {
maxConnections: 20,
},
cache: {
idleTimeoutMillis: 60000,
},
queue: {
enableMetrics: true,
},
},
};
expect(config.service).toBe('test-service');
expect(config.environment).toBe('development');
expect(config.pools?.mongodb?.poolSize).toBe(10);
expect(config.pools?.postgres?.maxConnections).toBe(20);
});
it('should allow minimal factory config', () => {
const config: ConnectionFactoryConfig = {
service: 'minimal-service',
environment: 'test',
};
expect(config.service).toBe('minimal-service');
expect(config.pools).toBeUndefined();
});
});
describe('ConnectionPool', () => {
it('should define connection pool interface', () => {
const mockPool: ConnectionPool<any> = {
name: 'test-pool',
client: { connected: true },
metrics: {
created: new Date(),
totalConnections: 10,
activeConnections: 5,
idleConnections: 5,
waitingRequests: 0,
errors: 0,
},
health: async () => true,
dispose: async () => {},
};
expect(mockPool.name).toBe('test-pool');
expect(mockPool.client.connected).toBe(true);
expect(mockPool.metrics.totalConnections).toBe(10);
});
});
describe('PoolMetrics', () => {
it('should define pool metrics structure', () => {
const metrics: PoolMetrics = {
created: new Date('2024-01-01'),
totalConnections: 100,
activeConnections: 25,
idleConnections: 75,
waitingRequests: 2,
errors: 3,
};
expect(metrics.totalConnections).toBe(100);
expect(metrics.activeConnections).toBe(25);
expect(metrics.idleConnections).toBe(75);
expect(metrics.waitingRequests).toBe(2);
expect(metrics.errors).toBe(3);
});
});
describe('ConnectionFactory', () => {
it('should define connection factory interface', () => {
const mockFactory: ConnectionFactory = {
createMongoDB: async (config) => ({
name: config.name,
client: {},
metrics: {
created: new Date(),
totalConnections: 0,
activeConnections: 0,
idleConnections: 0,
waitingRequests: 0,
errors: 0,
},
health: async () => true,
dispose: async () => {},
}),
createPostgreSQL: async (config) => ({
name: config.name,
client: {},
metrics: {
created: new Date(),
totalConnections: 0,
activeConnections: 0,
idleConnections: 0,
waitingRequests: 0,
errors: 0,
},
health: async () => true,
dispose: async () => {},
}),
createCache: async (config) => ({
name: config.name,
client: {},
metrics: {
created: new Date(),
totalConnections: 0,
activeConnections: 0,
idleConnections: 0,
waitingRequests: 0,
errors: 0,
},
health: async () => true,
dispose: async () => {},
}),
createQueue: async (config) => ({
name: config.name,
client: {},
metrics: {
created: new Date(),
totalConnections: 0,
activeConnections: 0,
idleConnections: 0,
waitingRequests: 0,
errors: 0,
},
health: async () => true,
dispose: async () => {},
}),
getPool: (type, name) => undefined,
listPools: () => [],
disposeAll: async () => {},
};
expect(mockFactory.createMongoDB).toBeDefined();
expect(mockFactory.createPostgreSQL).toBeDefined();
expect(mockFactory.createCache).toBeDefined();
expect(mockFactory.createQueue).toBeDefined();
expect(mockFactory.getPool).toBeDefined();
expect(mockFactory.listPools).toBeDefined();
expect(mockFactory.disposeAll).toBeDefined();
});
});
});