import { afterEach, describe, expect, it, vi } from 'vitest'; import { configSchema } from '../config/schema.js'; import { Lifecycle } from './lifecycle.js'; import { startServices } from './services.js'; vi.mock('../automation/index.js', () => { return { HeartbeatMonitor: class { start(): void {} stop(): void {} }, MinioSyncScheduler: class { start(): void {} stop(): void {} }, }; }); function makeConfig(overrides: Record = {}) { return configSchema.parse({ telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, models: { default: { provider: 'anthropic', model: 'claude-3' } }, ...overrides, }); } describe('startServices startup ordering', () => { afterEach(async () => { vi.restoreAllMocks(); }); it('fails after bounded retries on persistent gateway bind collision before starting channels', async () => { const lifecycle = new Lifecycle(); const config = makeConfig({ server: { localhost: true, port: 18800 } }); const startError = new Error('listen EADDRINUSE: address already in use 127.0.0.1:18800') as Error & { code?: string }; startError.code = 'EADDRINUSE'; const channelRegistry = { startAll: vi.fn(async () => {}), stopAll: vi.fn(async () => {}), }; const gateway = { start: vi.fn(async () => { throw startError; }), stop: vi.fn(async () => {}), getMetrics: vi.fn(() => ({ getModelMetrics: () => [] })), }; await expect(startServices({ config, lifecycle, channelRegistry: channelRegistry as never, gateway: gateway as never, modelRouter: {} as never, memoryDir: '/tmp', dataDir: '/tmp', gatewayStartRetry: { maxAttempts: 3, retryDelayMs: 0, sleep: async () => {}, }, })).rejects.toThrow('Gateway bind failed'); expect(channelRegistry.startAll).not.toHaveBeenCalled(); expect(channelRegistry.stopAll).not.toHaveBeenCalled(); expect(gateway.start).toHaveBeenCalledTimes(3); expect(gateway.stop).toHaveBeenCalledTimes(3); }); it('retries gateway bind collisions and then starts channels on success', async () => { const lifecycle = new Lifecycle(); const config = makeConfig({ server: { localhost: true, port: 18800 } }); const startError = new Error('listen EADDRINUSE: address already in use 127.0.0.1:18800') as Error & { code?: string }; startError.code = 'EADDRINUSE'; const order: string[] = []; const channelRegistry = { startAll: vi.fn(async () => { order.push('channels.start'); }), stopAll: vi.fn(async () => {}), }; const gateway = { start: vi.fn(async () => { order.push('gateway.start'); if (gateway.start.mock.calls.length === 1) { throw startError; } }), stop: vi.fn(async () => { order.push('gateway.stop'); }), getMetrics: vi.fn(() => ({ getModelMetrics: () => [] })), }; await startServices({ config, lifecycle, channelRegistry: channelRegistry as never, gateway: gateway as never, modelRouter: {} as never, memoryDir: '/tmp', dataDir: '/tmp', gatewayStartRetry: { maxAttempts: 3, retryDelayMs: 0, sleep: async () => {}, }, }); expect(order).toEqual(['gateway.start', 'gateway.stop', 'gateway.start', 'channels.start']); expect(channelRegistry.startAll).toHaveBeenCalledOnce(); await lifecycle.shutdown(); }); it('starts gateway before channels when startup succeeds', async () => { const lifecycle = new Lifecycle(); const config = makeConfig({ server: { localhost: true, port: 18800 } }); const order: string[] = []; const channelRegistry = { startAll: vi.fn(async () => { order.push('channels.start'); }), stopAll: vi.fn(async () => {}), }; const gateway = { start: vi.fn(async () => { order.push('gateway.start'); }), stop: vi.fn(async () => {}), getMetrics: vi.fn(() => ({ getModelMetrics: () => [] })), }; await startServices({ config, lifecycle, channelRegistry: channelRegistry as never, gateway: gateway as never, modelRouter: {} as never, memoryDir: '/tmp', dataDir: '/tmp', }); expect(order).toEqual(['gateway.start', 'channels.start']); await lifecycle.shutdown(); }); });