Files
adam-acs/tests/unit/services/cwmp/state_machine_manager.spec.ts
T

447 lines
18 KiB
TypeScript

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<string, any>
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()
})
})