import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@adonisjs/core/services/logger', () => ({ default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, })) vi.mock('../../../../app/models/device.js', () => ({ default: { createOrUpdate: vi.fn().mockResolvedValue({}), findByDeviceId: vi.fn().mockResolvedValue(null), updateOne: vi.fn().mockResolvedValue({}) }, })) vi.mock('../../../../app/models/command.js', () => ({ default: { getCollection: vi.fn().mockReturnValue({ find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), countDocuments: vi.fn().mockResolvedValue(0) }), updateCommand: vi.fn().mockResolvedValue({}), }, })) import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js' import { Device } from '../../../../app/domain/device/device.js' import { DeviceId } from '../../../../app/domain/device/device_id.js' import { CWMPTaskQueue, ResultStatus } from '../../../../app/services/cwmp/task_queue.js' import CommandModel from '../../../../app/models/command.js' function makeDevice(): Device { const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) device.setDataModel('tr-181') device.setGpnStatus('unsupported') return device } describe('StateMachineManager — coverage', () => { let manager: StateMachineManager beforeEach(() => { vi.clearAllMocks() ;(StateMachineManager as any).instance = null manager = StateMachineManager.getInstance() }) // --- Event routing --- it('routes BOOTSTRAP event to MainStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-1', { events: ['0 BOOTSTRAP'] }) expect(machine.status).toBe(MachineStatus.RUNNING) // MainStateMachine has getCurrentStateName expect(machine.stateMachine.getCurrentStateName()).toBeDefined() }) it('routes CONNECTION_REQUEST to ImmediateCommandStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-2', { events: ['6 CONNECTION REQUEST'] }) expect(machine.status).toBe(MachineStatus.RUNNING) expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') }) it('routes PERIODIC to MainStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-3', { events: ['2 PERIODIC'] }) expect(machine.status).toBe(MachineStatus.RUNNING) expect(machine.stateMachine.getCurrentStateName()).toBe('idle') }) it('forces MainStateMachine when NI commands exist', async () => { // Override the mock to return count > 0 const collection = CommandModel.getCollection() vi.mocked(collection.countDocuments).mockResolvedValueOnce(3) const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-4', { events: ['6 CONNECTION REQUEST'] }) // Should be MainStateMachine (idle) not ImmediateCommandStateMachine expect(machine.stateMachine.getCurrentStateName()).toBe('idle') }) it('preempts ImmediateCommandStateMachine on BOOT event with same session', async () => { const device = makeDevice() // First: create ImmediateCommandStateMachine const machine1 = await manager.startStateMachine(device, 'sess-5', { events: ['6 CONNECTION REQUEST'] }) expect(machine1.stateMachine.getCurrentStateName()).toBe('executing-commands') // Second: BOOT event arrives on same session (same TCP connection, new Inform) const machine2 = await manager.startStateMachine(device, 'sess-5', { events: ['1 BOOT'] }) // Should have preempted to MainStateMachine (same machine instance, new state machine inside) // Since gpnStatus=unsupported, it starts and goes directly to critical-reads const stateName = machine2.stateMachine.getCurrentStateName() expect(stateName === 'idle' || stateName === 'critical-reads').toBe(true) }) // --- continue() --- it('continue() calls handleResponse on state machine', async () => { const device = makeDevice() await manager.startStateMachine(device, 'sess-6', { events: ['0 BOOTSTRAP'] }) // continue sends empty POST handling const response = await manager.continue('sess-6') // Should have produced a GPV (critical reads since gpn=unsupported) expect(response).toContain('GetParameterValues') }) it('continue() treats empty body as task failure when lastTaskId is set', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-7', { events: ['0 BOOTSTRAP'] }) // Manually set lastTaskId to simulate in-flight task machine.lastTaskId = 'fake-task-id' // Add a fake task to the queue so failTask doesn't error manager.getTaskQueue().addCWMPTask(device.deviceId, 'get_parameter_values', []) const response = await manager.continue('sess-7') // Should have treated as failure and advanced the state machine expect(machine.lastTaskId).toBeUndefined() }) it('continue() returns empty for unknown session', async () => { const response = await manager.continue('unknown-session') expect(response).toBe('') }) // --- handleCWMPResponse() --- it('handleCWMPResponse() correlates response with lastTaskId and executes callback', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-8', { events: ['0 BOOTSTRAP'] }) // Start state machine (sends GPV) await manager.continue('sess-8') // Manually set lastTaskId const taskId = 'test-task-123' machine.lastTaskId = taskId const callbackFn = vi.fn().mockResolvedValue('') manager.getTaskQueue()['taskCallbacks'].set(taskId, callbackFn) manager.getTaskQueue()['tasks'].set(taskId, { id: taskId, deviceId: device.deviceId, type: 'get_parameter_values', payload: [], status: 'SENT' as any, createdAt: new Date() }) // Send success response await manager.handleCWMPResponse('sess-8', true, { ParameterList: { ParameterValueStruct: [] } }) // Callback should have been invoked expect(callbackFn).toHaveBeenCalled() expect(machine.lastTaskId).toBeUndefined() }) it('handleCWMPResponse() returns empty for unknown session', async () => { const response = await manager.handleCWMPResponse('no-such-session', true, {}) expect(response).toBe('') }) // --- Session helpers --- it('hasSession returns false for unknown session', () => { expect(manager.hasSession('nonexistent')).toBe(false) }) it('hasSession returns true for registered session', async () => { const device = makeDevice() await manager.startStateMachine(device, 'sess-9', { events: ['0 BOOTSTRAP'] }) expect(manager.hasSession('sess-9')).toBe(true) }) it('ensureSessionForRemote rebinds session correctly', async () => { const device = makeDevice() await manager.startStateMachine(device, 'sess-10', { events: ['0 BOOTSTRAP'] }) manager.bindRemoteToDevice('remote-1', device.deviceId) const result = manager.ensureSessionForRemote('remote-1', 'sess-10-new') expect(result).toBe(true) expect(manager.hasSession('sess-10-new')).toBe(true) expect(manager.hasSession('sess-10')).toBe(false) }) it('ensureSessionForRemote returns false for unknown remote', () => { expect(manager.ensureSessionForRemote('unknown-remote', 'sess')).toBe(false) }) // --- Cleanup --- it('cleanupCompletedMachines removes old completed machines', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-11', { events: ['0 BOOTSTRAP'] }) // Force complete and set old completion date ;(machine as any).status = MachineStatus.COMPLETED ;(machine as any).completedAt = new Date(Date.now() - 2 * 60 * 60 * 1000) // 2h ago // Verify machine exists in internal map before cleanup const machinesMap = (manager as any).machines as Map expect(machinesMap.has(machine.id)).toBe(true) manager.cleanupCompletedMachines(1) // Machine should be removed from internal map expect(machinesMap.has(machine.id)).toBe(false) }) }) describe('StateMachineManager — additional coverage', () => { let manager: StateMachineManager beforeEach(() => { vi.clearAllMocks() ;(StateMachineManager as any).instance = null manager = StateMachineManager.getInstance() }) // --- Event routing for less common events --- it('routes VALUE_CHANGE to MainStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-vc', { events: ['4 VALUE CHANGE'] }) expect(machine.status).toBe(MachineStatus.RUNNING) expect(machine.stateMachine.getCurrentStateName()).toBeDefined() }) it('routes DIAGNOSTICS_COMPLETE to ImmediateCommandStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-dc', { events: ['8 DIAGNOSTICS COMPLETE'] }) expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') }) it('routes TRANSFER_COMPLETE to ImmediateCommandStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-tc', { events: ['7 TRANSFER COMPLETE'] }) expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') }) it('routes SCHEDULED to MainStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-sc', { events: ['3 SCHEDULED'] }) expect(machine.stateMachine.getCurrentStateName()).toBeDefined() }) it('routes unknown event to MainStateMachine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-unk', { events: ['99 UNKNOWN'] }) expect(machine.stateMachine.getCurrentStateName()).toBeDefined() }) // --- getHashIdForRemote --- it('getHashIdForRemote returns hash for bound remote', async () => { const device = makeDevice() await manager.startStateMachine(device, 'sess-hr', { events: ['0 BOOTSTRAP'] }) manager.bindRemoteToDevice('10.0.0.1:5000', device.deviceId) const hash = manager.getHashIdForRemote('10.0.0.1:5000') expect(hash).toBeDefined() expect(typeof hash).toBe('string') expect(hash!.length).toBeGreaterThan(0) }) it('getHashIdForRemote returns undefined for unknown remote', () => { expect(manager.getHashIdForRemote('unknown:9999')).toBeUndefined() }) // --- handleCWMPResponse with fault --- it('handleCWMPResponse calls failTask on fault response when lastTaskId set', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-fault', { events: ['0 BOOTSTRAP'] }) await manager.continue('sess-fault') // Set lastTaskId const taskId = 'task-fail-test' machine.lastTaskId = taskId const taskQueue = manager.getTaskQueue() ;(taskQueue as any).tasks.set(taskId, { id: taskId, deviceId: device.deviceId, type: 'test', payload: {}, status: 'SENT', createdAt: new Date() }) ;(taskQueue as any).taskCallbacks.set(taskId, vi.fn().mockResolvedValue('')) await manager.handleCWMPResponse('sess-fault', false, { FaultCode: 9001 }) // lastTaskId should be cleared expect(machine.lastTaskId).toBeUndefined() }) // --- continue() serving task from queue --- it('continue() serves pending task from queue when state machine returns empty', async () => { // Start the immediate command SM (completes with no commands) await manager.continue('sess-q') // After completion, continue should return '' const response = await manager.continue('sess-q') expect(response).toBe('') }) // --- CONNECTION REQUEST replaces existing immediate --- it('new CONNECTION_REQUEST completes old immediate machine and creates fresh', async () => { const device = makeDevice() const machine1 = await manager.startStateMachine(device, 'sess-cr1', { events: ['6 CONNECTION REQUEST'] }) expect(machine1.status).toBe(MachineStatus.RUNNING) // New CONNECTION REQUEST on different session const machine2 = await manager.startStateMachine(device, 'sess-cr2', { events: ['6 CONNECTION REQUEST'] }) expect(machine1.status).toBe(MachineStatus.COMPLETED) expect(machine2.session).toBe('sess-cr2') }) // --- stopCleanupTimer / startCleanupTimer --- it('startCleanupTimer and stopCleanupTimer work without error', () => { manager.startCleanupTimer(100000) manager.startCleanupTimer(100000) // second call should be no-op manager.stopCleanupTimer() manager.stopCleanupTimer() // second stop should be no-op }) // --- shutdown --- it('shutdown stops timer and cleans up', async () => { manager.startCleanupTimer(100000) await manager.shutdown() // Should not throw }) // --- enqueueImmediate --- it('enqueueImmediate adds task to queue', async () => { const device = makeDevice() await manager.enqueueImmediate(device, 'get_parameter_values', ['Device.Info.SW']) const task = manager.getTaskQueue().getTaskForDevice(device.deviceId) expect(task).toBeDefined() expect(task!.task).toBe('get_parameter_values') }) }) describe('StateMachineManager — error and edge case branches', () => { let manager: StateMachineManager beforeEach(() => { vi.clearAllMocks() ;(StateMachineManager as any).instance = null manager = StateMachineManager.getInstance() }) it('handleCWMPResponse returns empty for non-RUNNING machine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-nr', { events: ['0 BOOTSTRAP'] }) ;(machine as any).status = MachineStatus.COMPLETED const result = await manager.handleCWMPResponse('sess-nr', true, {}) expect(result).toBe('') }) it('continue returns empty for non-RUNNING machine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-cnr', { events: ['0 BOOTSTRAP'] }) ;(machine as any).status = MachineStatus.FAILED const result = await manager.continue('sess-cnr') expect(result).toBe('') }) it('handleCWMPResponse catches state machine errors and fails machine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-err2', { events: ['0 BOOTSTRAP'] }) vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('boom')) const result = await manager.handleCWMPResponse('sess-err2', true, {}) expect(result).toBe('') expect(machine.status).toBe(MachineStatus.FAILED) }) it('continue catches errors and fails machine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-cerr2', { events: ['0 BOOTSTRAP'] }) vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('crash')) const result = await manager.continue('sess-cerr2') expect(result).toBe('') expect(machine.status).toBe(MachineStatus.FAILED) }) it('ensureSessionForRemote returns false for non-RUNNING machine', async () => { const device = makeDevice() const machine = await manager.startStateMachine(device, 'sess-enr2', { events: ['0 BOOTSTRAP'] }) manager.bindRemoteToDevice('remote-xx', device.deviceId) ;(machine as any).status = MachineStatus.COMPLETED const result = manager.ensureSessionForRemote('remote-xx', 'new-sess') expect(result).toBe(false) }) it('getHashIdForRemote returns undefined for invalid JSON', () => { ;(manager as any).remoteToDevice.set('bad-remote', 'not-valid-json{') expect(manager.getHashIdForRemote('bad-remote')).toBeUndefined() }) }) describe('StateMachineManager — stale session replacement', () => { let manager: StateMachineManager beforeEach(() => { vi.clearAllMocks() // Reset singleton ;(StateMachineManager as any).instance = null manager = StateMachineManager.getInstance() }) it('replaces stale MainStateMachine when session differs', async () => { const device = makeDevice() // Start first machine on session-A const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] }) expect(machine1.status).toBe(MachineStatus.RUNNING) expect(machine1.session).toBe('session-A') // Start second machine on session-B (different session, same device) const machine2 = await manager.startStateMachine(device, 'session-B', { events: ['2 PERIODIC'] }) // Should be a NEW machine (not the old one rebinding) expect(machine2.session).toBe('session-B') // Old machine should be completed expect(machine1.status).toBe(MachineStatus.COMPLETED) }) it('does NOT replace machine when session is same', async () => { const device = makeDevice() // Start machine on session-A const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] }) // Start again on SAME session const machine2 = await manager.startStateMachine(device, 'session-A', { events: ['2 PERIODIC'] }) // Should return the same machine (rebinding) expect(machine2.id).toBe(machine1.id) expect(machine1.status).toBe(MachineStatus.RUNNING) }) it('creates fresh machine after replacing stale one', async () => { const device = makeDevice() // Start first machine await manager.startStateMachine(device, 'session-old', { events: ['0 BOOTSTRAP'] }) // Start new machine with different session const newMachine = await manager.startStateMachine(device, 'session-new', { events: ['2 PERIODIC'] }) // Fresh machine should be running expect(newMachine.status).toBe(MachineStatus.RUNNING) expect(newMachine.session).toBe('session-new') // Should be a MainStateMachine (PERIODIC event triggers MainStateMachine) expect(newMachine.currentState).toBeDefined() }) })