import { WorkerHeartbeatService, WorkerStatus } from './worker-heartbeat.service'; import { RedisService } from '../infrastructure/redis/redis.service'; describe('WorkerHeartbeatService', () => { let service: WorkerHeartbeatService; let mockRedis: jest.Mocked>; beforeEach(() => { mockRedis = { set: jest.fn().mockResolvedValue(undefined), get: jest.fn().mockResolvedValue(null), del: jest.fn().mockResolvedValue(undefined), keys: jest.fn().mockResolvedValue([]), ttl: jest.fn().mockResolvedValue(-2), }; process.env.WORKER_INSTANCE_ID = 'test-worker-1'; service = new WorkerHeartbeatService(mockRedis as any); }); afterEach(async () => { await service.onModuleDestroy().catch(() => {}); delete process.env.WORKER_INSTANCE_ID; jest.useRealTimers(); }); describe('onModuleInit', () => { it('writes heartbeat immediately and starts interval', async () => { jest.useFakeTimers(); await service.onModuleInit(); expect(mockRedis.set).toHaveBeenCalledTimes(1); expect(mockRedis.set).toHaveBeenCalledWith( 'worker:heartbeat:test-worker-1', expect.any(String), 90, ); // Parse the heartbeat data const data = JSON.parse((mockRedis.set as jest.Mock).mock.calls[0][1]); expect(data.instanceId).toBe('test-worker-1'); expect(data.queues).toBeInstanceOf(Array); expect(data.queues.length).toBeGreaterThanOrEqual(1); expect(data.hostname).toBeDefined(); expect(data.appVersion).toBeDefined(); expect(data.startedAt).toBeDefined(); }); it('writes heartbeat periodically', async () => { jest.useFakeTimers(); await service.onModuleInit(); expect(mockRedis.set).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(30_000); await Promise.resolve(); expect(mockRedis.set).toHaveBeenCalledTimes(2); jest.advanceTimersByTime(30_000); await Promise.resolve(); expect(mockRedis.set).toHaveBeenCalledTimes(3); }); it('uses instanceId from WORKER_INSTANCE_ID env var', async () => { process.env.WORKER_INSTANCE_ID = 'custom-instance'; const svc = new WorkerHeartbeatService(mockRedis as any); await svc.onModuleInit(); expect(mockRedis.set).toHaveBeenCalledWith( 'worker:heartbeat:custom-instance', expect.any(String), 90, ); await svc.onModuleDestroy(); }); }); describe('onModuleDestroy', () => { it('stops interval and deletes heartbeat key', async () => { jest.useFakeTimers(); await service.onModuleInit(); expect(mockRedis.set).toHaveBeenCalledTimes(1); await service.onModuleDestroy(); // Advance time — no more beats jest.advanceTimersByTime(30_000); await Promise.resolve(); expect(mockRedis.set).toHaveBeenCalledTimes(1); // Key deleted expect(mockRedis.del).toHaveBeenCalledWith('worker:heartbeat:test-worker-1'); }); }); describe('computeStatuses', () => { it('returns empty array when no heartbeat keys', async () => { (mockRedis.keys as jest.Mock).mockResolvedValue([]); const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any); expect(result).toEqual([]); }); it('returns online when TTL > 30', async () => { (mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']); (mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify({ instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(), queues: ['ai-analysis'], hostname: 'host1', })); (mockRedis.ttl as jest.Mock).mockResolvedValue(60); const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any); expect(result[0].status).toBe('online'); }); it('returns stale when TTL between 1 and 30', async () => { (mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']); (mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify({ instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(), queues: ['ai-analysis'], hostname: 'host1', })); (mockRedis.ttl as jest.Mock).mockResolvedValue(15); const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any); expect(result[0].status).toBe('stale'); }); it('returns offline when TTL < 0', async () => { (mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']); (mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify({ instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(), queues: ['ai-analysis'], hostname: 'host1', })); (mockRedis.ttl as jest.Mock).mockResolvedValue(-1); const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any); expect(result[0].status).toBe('offline'); }); it('handles multiple worker instances', async () => { (mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1', 'worker:heartbeat:w2']); (mockRedis.get as jest.Mock) .mockResolvedValueOnce(JSON.stringify({ instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(), queues: ['ai-analysis'], hostname: 'host1' })) .mockResolvedValueOnce(JSON.stringify({ instanceId: 'w2', appVersion: '0.0.1', startedAt: new Date().toISOString(), queues: ['ai-analysis'], hostname: 'host2' })); (mockRedis.ttl as jest.Mock) .mockResolvedValueOnce(60) .mockResolvedValueOnce(10); const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any); expect(result).toHaveLength(2); expect(result[0].status).toBe('online'); expect(result[1].status).toBe('stale'); }); it('skips malformed JSON data', async () => { (mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']); (mockRedis.get as jest.Mock).mockResolvedValue('not-json'); const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any); expect(result).toEqual([]); }); }); describe('API heartbeat key ≠ Worker heartbeat key', () => { it('API process does not have WorkerHeartbeatService in its DI graph', () => { // WorkerHeartbeatService is only registered in WorkerModule, // not in AppModule. This is verified by the module configuration: // - AppModule does not import WorkerHeartbeatService // - WorkerModule imports and provides WorkerHeartbeatService // This test documents the architectural constraint. expect(WorkerHeartbeatService).toBeDefined(); }); }); });