stock-bot/libs/core/di/test/service-application.test.ts
2025-06-26 16:12:27 -04:00

598 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import type { BaseAppConfig } from '@stock-bot/config';
import { ServiceApplication } from '../src/service-application';
import type { ServiceApplicationConfig, ServiceLifecycleHooks } from '../src/service-application';
// 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);
});
});
});