import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock logger vi.mock('@adonisjs/core/services/logger', () => ({ default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, })) // Mock DeviceModel vi.mock('../../../../app/models/device.js', () => ({ default: { createOrUpdate: vi.fn().mockResolvedValue({}), findByDeviceId: vi.fn().mockResolvedValue(null), updateOne: vi.fn().mockResolvedValue({}), }, })) // Mock CommandModel 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 { ResultStatus } from '../../../../app/services/cwmp/task_queue.js' import CommandModel from '../../../../app/models/command.js' function makeDevice(gpnStatus: string = 'unsupported'): Device { const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) device.setDataModel('tr-181') device.setGpnStatus(gpnStatus as any) 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) }) })