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 = { 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('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()), onShutdown: mock(() => {}), 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, () => { const { Hono } = require('hono'); const routes = new Hono(); routes.get('/test', c => c.json({ ok: true })); return routes; } ); 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()), onShutdown: mock(() => {}), 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, () => { const { Hono } = require('hono'); const routes = new Hono(); routes.get('/test', c => c.json({ ok: true })); return routes; } ); // Should have registered shutdown handlers expect(mockShutdownInstance.onShutdown).toHaveBeenCalledTimes(5); // Queue, HTTP, Custom, Services, Loggers // Test the handlers by calling them const shutdownHandlers = (mockShutdownInstance.onShutdown as any).mock.calls; // Find and execute queue shutdown handler (priority 9) const queueHandler = shutdownHandlers.find(call => call[2] === 'Queue System'); if (queueHandler) { await queueHandler[0](); expect(mockContainer.resolve).toHaveBeenCalledWith('queueManager'); } // Find and execute services shutdown handler (priority 5) const servicesHandler = shutdownHandlers.find(call => call[2] === 'Services'); await servicesHandler[0](); expect(mockContainer.resolve).toHaveBeenCalledWith('mongoClient'); expect(mockContainer.resolve).toHaveBeenCalledWith('postgresClient'); expect(mockContainer.resolve).toHaveBeenCalledWith('questdbClient'); // Logger shutdown handler is also registered }); }); 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, () => { const { Hono } = require('hono'); const routes = new Hono(); routes.get('/test', c => c.json({ ok: true })); return routes; } ); 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, () => { const { Hono } = require('hono'); const routes = new Hono(); routes.get('/test', c => c.json({ ok: true })); return routes; } ); const honoApp = app.getApp(); const response = await honoApp!.request('/'); expect(response.status).toBe(404); }); }); });