/** * Unit tests for DockerEventManager. * * Verifies that: * - Boot enumerates only local nodes and spawns one service each. * - 'node-added' for a local node spawns a service. * - 'node-added' for a remote node does nothing. * - 'node-removed' tears down the matching service. * - 'node-updated' respawns when type flips remote <-> local. * - Stop unsubscribes and tears down all services. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'events'; // ── Hoisted mocks ────────────────────────────────────────────────────── // Note: the hoisted factory runs before top-level imports resolve, so we // load `events` via require() here rather than referencing the top-level // EventEmitter import (which is not yet initialised at hoist time). A // top-level `await import()` would trigger TS1378 under the current // tsconfig, so require is the pragmatic choice for this hoisted factory. const { mockGetNodes, mockGetNode, serviceStart, serviceShutdown, DockerEventServiceCtor, registryInstance, } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { EventEmitter: HoistedEE } = require('events'); const start = vi.fn().mockResolvedValue(undefined); const shutdown = vi.fn(); // A real class (not a vi.fn) so `new` works reliably across vitest versions. class FakeDockerEventService { public readonly nodeId: number; public readonly nodeName: string; constructor(nodeId: number, nodeName: string) { this.nodeId = nodeId; this.nodeName = nodeName; } start() { return start(); } shutdown() { shutdown(); } getStatus() { return { nodeId: this.nodeId, nodeName: this.nodeName, status: 'connected' as const, reconnectAttempts: 0, trackedContainers: 0, }; } } const ctorSpy = vi.fn((nodeId: number, nodeName: string) => new FakeDockerEventService(nodeId, nodeName), ); // Wrap in a Proxy so `new Ctor(...)` both constructs via the real class // and records the call on the spy for assertions. const Ctor = new Proxy(FakeDockerEventService, { construct(_target, args: [number, string]) { ctorSpy(...args); return new FakeDockerEventService(...args); }, }); return { mockGetNodes: vi.fn(), mockGetNode: vi.fn(), serviceStart: start, serviceShutdown: shutdown, DockerEventServiceCtor: Object.assign(Ctor, { _spy: ctorSpy }), registryInstance: new HoistedEE() as EventEmitter, }; }); vi.mock('../services/DatabaseService', () => ({ DatabaseService: { getInstance: () => ({ getNodes: mockGetNodes, getNode: mockGetNode, }), }, })); vi.mock('../services/NodeRegistry', () => ({ NodeRegistry: { getInstance: () => registryInstance, }, })); vi.mock('../services/DockerEventService', () => ({ DockerEventService: DockerEventServiceCtor, })); import { DockerEventManager } from '../services/DockerEventManager'; // Proxy records constructor calls on _spy for assertions. const ctorSpy = (DockerEventServiceCtor as unknown as { _spy: ReturnType })._spy; beforeEach(() => { vi.clearAllMocks(); // Reset singleton between tests. (DockerEventManager as unknown as { instance: DockerEventManager | undefined }).instance = undefined; registryInstance.removeAllListeners(); }); afterEach(() => { DockerEventManager.getInstance().stop(); }); describe('DockerEventManager - boot', () => { it('spawns a service for each local node and skips remote nodes', async () => { mockGetNodes.mockReturnValue([ { id: 1, name: 'local-a', type: 'local' }, { id: 2, name: 'remote-b', type: 'remote' }, { id: 3, name: 'local-c', type: 'local' }, ]); await DockerEventManager.getInstance().start(); expect(ctorSpy).toHaveBeenCalledTimes(2); expect(ctorSpy).toHaveBeenCalledWith(1, 'local-a'); expect(ctorSpy).toHaveBeenCalledWith(3, 'local-c'); expect(serviceStart).toHaveBeenCalledTimes(2); }); it('second start is a no-op', async () => { mockGetNodes.mockReturnValue([{ id: 1, name: 'a', type: 'local' }]); const mgr = DockerEventManager.getInstance(); await mgr.start(); await mgr.start(); expect(ctorSpy).toHaveBeenCalledTimes(1); }); }); describe('DockerEventManager - node lifecycle events', () => { it('spawns a service when a local node is added', async () => { mockGetNodes.mockReturnValue([]); await DockerEventManager.getInstance().start(); mockGetNode.mockReturnValue({ id: 5, name: 'new-local', type: 'local' }); registryInstance.emit('node-added', 5); await vi.waitFor(() => expect(serviceStart).toHaveBeenCalled()); expect(ctorSpy).toHaveBeenCalledWith(5, 'new-local'); }); it('does nothing when a remote node is added', async () => { mockGetNodes.mockReturnValue([]); await DockerEventManager.getInstance().start(); mockGetNode.mockReturnValue({ id: 6, name: 'new-remote', type: 'remote' }); registryInstance.emit('node-added', 6); // Give the async handler a tick to run. await new Promise(r => setTimeout(r, 0)); expect(ctorSpy).not.toHaveBeenCalled(); }); it('shuts down the service when a node is removed', async () => { mockGetNodes.mockReturnValue([{ id: 7, name: 'to-remove', type: 'local' }]); await DockerEventManager.getInstance().start(); expect(ctorSpy).toHaveBeenCalledTimes(1); registryInstance.emit('node-removed', 7); expect(serviceShutdown).toHaveBeenCalledTimes(1); }); it('respawns when a remote node becomes local', async () => { mockGetNodes.mockReturnValue([]); // starts with nothing await DockerEventManager.getInstance().start(); // Now the node exists and is local. mockGetNode.mockReturnValue({ id: 9, name: 'flipped', type: 'local' }); registryInstance.emit('node-updated', 9); await vi.waitFor(() => expect(serviceStart).toHaveBeenCalled()); expect(ctorSpy).toHaveBeenCalledWith(9, 'flipped'); }); it('tears down when a local node becomes remote', async () => { mockGetNodes.mockReturnValue([{ id: 10, name: 'was-local', type: 'local' }]); await DockerEventManager.getInstance().start(); expect(ctorSpy).toHaveBeenCalledTimes(1); mockGetNode.mockReturnValue({ id: 10, name: 'was-local', type: 'remote' }); registryInstance.emit('node-updated', 10); await new Promise(r => setTimeout(r, 0)); expect(serviceShutdown).toHaveBeenCalledTimes(1); }); }); describe('DockerEventManager - shutdown', () => { it('stops every service and removes listeners', async () => { mockGetNodes.mockReturnValue([ { id: 1, name: 'a', type: 'local' }, { id: 2, name: 'b', type: 'local' }, ]); const mgr = DockerEventManager.getInstance(); await mgr.start(); mgr.stop(); expect(serviceShutdown).toHaveBeenCalledTimes(2); expect(registryInstance.listenerCount('node-added')).toBe(0); expect(registryInstance.listenerCount('node-removed')).toBe(0); expect(registryInstance.listenerCount('node-updated')).toBe(0); }); });