/** * Tests for the graceful shutdown coordinator (DC-067). * * Covers: * - Constructor rejects bad inputs * - shutdown() emits 'shutdown' event with the signal name * - shutdown() stops each manager in declaration order * - shutdown() is idempotent — second call logs and returns * - shutdown() force-exits after drainTimeoutMs if server.close never fires * - shutdown() clears the force-exit timer when server.close fires first * - shutdown() catches manager.stop() throws so one bad manager doesn't * prevent the others from being stopped * - installSignalHandlers() registers for SIGTERM and SIGINT by default * * process.exit is mocked so tests don't actually kill the test runner. */ 'use strict'; const EventEmitter = require('events'); const { createShutdownCoordinator, installSignalHandlers, DEFAULT_DRAIN_TIMEOUT_MS, ShutdownCoordinator, } = require('../src/utilities/shutdown'); describe('ShutdownCoordinator (DC-067)', () => { let exitMock; let exitCalls; beforeEach(() => { exitCalls = []; // Use jest.spyOn so the mock is restored in afterEach (via clearMocks + // restoreMocks: true in jest.config.js). Direct assignment to process.exit // doesn't suppress Jest's process.exit watchlist which fails the test. exitMock = jest.spyOn(process, 'exit').mockImplementation((code) => { exitCalls.push(code); // Returning undefined prevents the test runner from actually exiting. return undefined; }); }); afterEach(() => { exitMock.mockRestore(); jest.clearAllTimers(); }); function makeFakeServer({ closeBehavior = 'sync' } = {}) { // 'sync' close calls back immediately. // 'never' close never calls back (used to test force-exit). if (closeBehavior === 'never') { return { close: jest.fn() }; } return { close: jest.fn((cb) => { cb(); }) }; } function makeFakeLog() { return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), }; } describe('constructor', () => { test('throws if server is missing', () => { expect(() => createShutdownCoordinator({ log: makeFakeLog(), managers: [] })) .toThrow('server is required'); }); test('throws if log is missing or invalid', () => { expect(() => createShutdownCoordinator({ server: makeFakeServer(), managers: [] })) .toThrow('log must have info'); expect(() => createShutdownCoordinator({ server: makeFakeServer(), log: { foo: 'bar' }, managers: [], })).toThrow('log must have info'); expect(() => createShutdownCoordinator({ server: makeFakeServer(), log: { info: () => {}, warn: () => {} }, // missing error managers: [], })).toThrow('log must have info'); // A log with all three methods should NOT throw. expect(() => createShutdownCoordinator({ server: makeFakeServer(), log: { info: () => {}, warn: () => {}, error: () => {} }, managers: [], })).not.toThrow(); }); test('uses DEFAULT_DRAIN_TIMEOUT_MS when drainTimeoutMs is not finite positive', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), drainTimeoutMs: 0, managers: [], }); expect(c.drainTimeoutMs).toBe(DEFAULT_DRAIN_TIMEOUT_MS); const c2 = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), drainTimeoutMs: NaN, managers: [], }); expect(c2.drainTimeoutMs).toBe(DEFAULT_DRAIN_TIMEOUT_MS); const c3 = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), drainTimeoutMs: 5000, managers: [], }); expect(c3.drainTimeoutMs).toBe(5000); }); test('defaults managers to [] when not an array', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), }); expect(c.managers).toEqual([]); }); test('is an EventEmitter', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); expect(c).toBeInstanceOf(EventEmitter); expect(c).toBeInstanceOf(ShutdownCoordinator); }); }); describe('shutdown()', () => { test('emits shutdown event with signal name', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const handler = jest.fn(); c.on('shutdown', handler); c.shutdown('SIGTERM'); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith('SIGTERM'); }); test('calls server.close() once', () => { const server = makeFakeServer(); const c = createShutdownCoordinator({ server, log: makeFakeLog(), managers: [], }); c.shutdown('SIGTERM'); expect(server.close).toHaveBeenCalledTimes(1); }); test('stops each manager in declaration order AFTER server.close fires', async () => { const order = []; const managers = [ { name: 'first', stop: jest.fn(() => { order.push('first'); }) }, { name: 'second', stop: jest.fn(() => { order.push('second'); }) }, { name: 'third', stop: jest.fn(() => { order.push('third'); }) }, ]; const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers, }); c.shutdown('SIGTERM'); // Wait for the async chain (server.close → _stopManagersInOrder → // process.exit) to settle. The mock exit is synchronous so this // resolves once all microtasks drain. await new Promise((resolve) => setImmediate(resolve)); expect(order).toEqual(['first', 'second', 'third']); }); test('does NOT stop managers until server.close callback fires', () => { const order = []; // Use a server whose close callback fires only when we manually call it. let deferredCloseCb; const server = { close: jest.fn((cb) => { deferredCloseCb = cb; }), }; const managers = [ { name: 'first', stop: jest.fn(() => { order.push('first'); }) }, ]; const c = createShutdownCoordinator({ server, log: makeFakeLog(), managers, }); c.shutdown('SIGTERM'); // server.close has been called but its callback hasn't fired yet. expect(server.close).toHaveBeenCalledTimes(1); // Manager has NOT been stopped yet — server is still draining. expect(order).toEqual([]); // Now fire the deferred callback to simulate drain completion. deferredCloseCb(); // Manager stopped AFTER server.close fired. expect(order).toEqual(['first']); }); test('continues stopping remaining managers if one throws', async () => { const order = []; const managers = [ { name: 'first', stop: jest.fn(() => { order.push('first'); }) }, { name: 'broken', stop: jest.fn(() => { throw new Error('boom'); }) }, { name: 'third', stop: jest.fn(() => { order.push('third'); }) }, ]; const log = makeFakeLog(); const c = createShutdownCoordinator({ server: makeFakeServer(), log, managers, }); c.shutdown('SIGTERM'); await new Promise((resolve) => setImmediate(resolve)); expect(order).toEqual(['first', 'third']); expect(log.warn).toHaveBeenCalledWith( 'shutdown', 'manager stop failed: broken', expect.objectContaining({ error: 'boom' }), ); }); test('is idempotent — second shutdown() returns without re-running', () => { const server = makeFakeServer(); const c = createShutdownCoordinator({ server, log: makeFakeLog(), managers: [{ name: 'm', stop: jest.fn() }], }); c.shutdown('SIGTERM'); c.shutdown('SIGTERM'); c.shutdown('SIGINT'); expect(server.close).toHaveBeenCalledTimes(1); expect(c.isShuttingDown()).toBe(true); }); test('isShuttingDown() flips false→true on first shutdown call', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); expect(c.isShuttingDown()).toBe(false); c.shutdown('SIGTERM'); expect(c.isShuttingDown()).toBe(true); }); test('force-exits after drainTimeoutMs if server.close never fires', () => { jest.useFakeTimers(); try { const server = makeFakeServer({ closeBehavior: 'never' }); const log = makeFakeLog(); const c = createShutdownCoordinator({ server, log, drainTimeoutMs: 1000, managers: [], }); c.shutdown('SIGTERM'); expect(exitCalls).toEqual([]); jest.advanceTimersByTime(999); expect(exitCalls).toEqual([]); jest.advanceTimersByTime(2); expect(exitCalls).toEqual([0]); expect(log.warn).toHaveBeenCalledWith( 'shutdown', 'drain timeout (1000ms) reached, force-exiting', ); } finally { jest.useRealTimers(); } }); test('clears force-exit timer when server.close fires first', () => { jest.useFakeTimers(); try { const server = makeFakeServer(); // calls back on the same tick const log = makeFakeLog(); const c = createShutdownCoordinator({ server, log, drainTimeoutMs: 1000, managers: [], }); c.shutdown('SIGTERM'); // The close callback is async (it awaits _stopManagersInOrder), // so the synchronous c.shutdown() returns BEFORE process.exit(0) // is recorded. Flush pending timers + microtasks. jest.runAllTimers(); // Flush microtasks queued by the async close callback. return Promise.resolve().then(() => Promise.resolve()).then(() => { expect(exitCalls).toEqual([0]); // Advance well past the drain timeout — no extra exit should fire // because the close callback cleared the force-exit timer. jest.advanceTimersByTime(5000); expect(exitCalls).toEqual([0]); }); } finally { jest.useRealTimers(); } }); }); describe('installSignalHandlers()', () => { test('registers listeners on the given signals', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const shutdownSpy = jest.spyOn(c, 'shutdown'); installSignalHandlers(c); // Emit fake signals through process.emit to verify the listener was // registered (process.on listens to the process EventEmitter). process.emit('SIGTERM'); process.emit('SIGINT'); expect(shutdownSpy).toHaveBeenCalledWith('SIGTERM'); expect(shutdownSpy).toHaveBeenCalledWith('SIGINT'); }); test('accepts custom signal list', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const shutdownSpy = jest.spyOn(c, 'shutdown'); installSignalHandlers(c, ['SIGHUP']); process.emit('SIGHUP'); expect(shutdownSpy).toHaveBeenCalledWith('SIGHUP'); }); test('is idempotent — calling installSignalHandlers twice does not double-register', () => { const c = createShutdownCoordinator({ server: makeFakeServer(), log: makeFakeLog(), managers: [], }); const shutdownSpy = jest.spyOn(c, 'shutdown'); installSignalHandlers(c); installSignalHandlers(c); // second call installSignalHandlers(c); // third call // The installedSignals tracker should have one entry per signal. expect(c._installedSignals).toEqual(['SIGTERM', 'SIGINT']); process.emit('SIGTERM'); expect(shutdownSpy).toHaveBeenCalledTimes(1); }); }); });