569 lines
No EOL
17 KiB
TypeScript
569 lines
No EOL
17 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}); |