Adds targeted unit tests to cover previously untested branches/functions in the business-critical (non-protocol) services: - device_service.ts: 35.8% -> 98.76% (CRUD, executeCommand, refreshDevice, calculatePagination, IPv4/IPv6 Connection Request fallback chain) - domain/device/device.ts: 76.7% -> 96.59% (GPN circuit breaker state machine, session lifecycle, canExecuteCommands, detectDataModelFromParams) - services/references/reference_engine.ts: 78.66% -> 100% (provider loading edge cases, rejection handling, singleton lifecycle) - services/configuration_engine.ts: 70.96% -> 98.7% (matchesCriteria branches, shouldApplyOnce policy variants, dryRun, complianceReport) - services/cwmp/state_machine_manager.ts: 70.9% -> 87.27% (getHashIdForRemoteIp, ensureSessionForRemote IP fallback, task callback error handling) - services/cwmp/main_state_machine.ts: 57.79% -> 94.29% (ExecutingCommandsState full command-type dispatch, MAX_STATE_TRANSITIONS guard, error-> PartialOperationalState transitions) Also fixes: - reference_engine_full.spec.ts previously depended on the real config/references.json (production config, wassup provider legitimately disabled there) instead of a test fixture — now mocks config_loader for deterministic, isolated assertions - main_state_machine.spec.ts CommandModel.getCollection mock returned a new object per call instead of a shared instance, breaking setupCommandMock() for the new command-dispatch tests Overall project coverage (excluding protocol/): 72.76% -> 82.34% statements. Full suite: 1005/1005 tests passing.
753 lines
28 KiB
TypeScript
753 lines
28 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()
|
|
})
|
|
|
|
it('startStateMachine tolerates errors while checking NI commands (falls back to non-forced routing)', async () => {
|
|
const collection = CommandModel.getCollection()
|
|
vi.mocked(collection.countDocuments).mockRejectedValueOnce(new Error('db down'))
|
|
const device = makeDevice()
|
|
const machine = await manager.startStateMachine(device, 'sess-nierr', {
|
|
events: ['6 CONNECTION REQUEST'],
|
|
})
|
|
// Falls back to normal routing (ImmediateCommandStateMachine for CONNECTION REQUEST)
|
|
expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands')
|
|
})
|
|
})
|
|
|
|
describe('StateMachineManager — getHashIdForRemoteIp', () => {
|
|
let manager: StateMachineManager
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
;(StateMachineManager as any).instance = null
|
|
manager = StateMachineManager.getInstance()
|
|
})
|
|
|
|
it('returns the hash id for a single unambiguous RUNNING device matching the IP', async () => {
|
|
const device = makeDevice()
|
|
await manager.startStateMachine(device, 'sess-ip1', { events: ['0 BOOTSTRAP'] })
|
|
manager.bindRemoteToDevice('10.0.0.5:12345', device.deviceId)
|
|
|
|
const hash = manager.getHashIdForRemoteIp('10.0.0.5')
|
|
expect(hash).toBeDefined()
|
|
expect(typeof hash).toBe('string')
|
|
})
|
|
|
|
it('matches IPv6 addresses with brackets stripped', async () => {
|
|
const device = makeDevice()
|
|
await manager.startStateMachine(device, 'sess-ip6', { events: ['0 BOOTSTRAP'] })
|
|
manager.bindRemoteToDevice('[::1]:7547', device.deviceId)
|
|
|
|
const hash = manager.getHashIdForRemoteIp('::1')
|
|
expect(hash).toBeDefined()
|
|
})
|
|
|
|
it('returns undefined when zero devices match the IP', () => {
|
|
expect(manager.getHashIdForRemoteIp('9.9.9.9')).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined when the matching device has no RUNNING machine', async () => {
|
|
const device = makeDevice()
|
|
const machine = await manager.startStateMachine(device, 'sess-ipdone', {
|
|
events: ['0 BOOTSTRAP'],
|
|
})
|
|
manager.bindRemoteToDevice('10.0.0.9:1', device.deviceId)
|
|
;(machine as any).status = MachineStatus.COMPLETED
|
|
|
|
expect(manager.getHashIdForRemoteIp('10.0.0.9')).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined when multiple RUNNING devices match the same IP (ambiguous NAT/CGNAT)', async () => {
|
|
const device1 = makeDevice()
|
|
const device2 = new (device1.constructor as any)(
|
|
new (Object.getPrototypeOf(device1.deviceId).constructor)(
|
|
'Sagemcom',
|
|
'AABBCC',
|
|
'Other',
|
|
'SN2'
|
|
)
|
|
)
|
|
await manager.startStateMachine(device1, 'sess-amb1', { events: ['0 BOOTSTRAP'] })
|
|
await manager.startStateMachine(device2, 'sess-amb2', { events: ['0 BOOTSTRAP'] })
|
|
manager.bindRemoteToDevice('10.0.0.7:1', device1.deviceId)
|
|
manager.bindRemoteToDevice('10.0.0.7:2', device2.deviceId)
|
|
|
|
expect(manager.getHashIdForRemoteIp('10.0.0.7')).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined for invalid JSON in the single-candidate path', async () => {
|
|
;(manager as any).remoteToDevice.set('10.0.0.8:1', 'not-valid-json{')
|
|
;(manager as any).deviceSessions.set('not-valid-json{', 'fake-session')
|
|
;(manager as any).sessionMachines.set('fake-session', 'fake-machine-id')
|
|
;(manager as any).machines.set('fake-machine-id', { status: MachineStatus.RUNNING })
|
|
|
|
expect(manager.getHashIdForRemoteIp('10.0.0.8')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('StateMachineManager — ensureSessionForRemote IP fallback', () => {
|
|
let manager: StateMachineManager
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
;(StateMachineManager as any).instance = null
|
|
manager = StateMachineManager.getInstance()
|
|
})
|
|
|
|
it('rebinds via IP-only fallback when the exact remoteKey is unknown (new TCP port)', async () => {
|
|
const device = makeDevice()
|
|
await manager.startStateMachine(device, 'sess-fb1', { events: ['0 BOOTSTRAP'] })
|
|
manager.bindRemoteToDevice('10.0.0.20:1000', device.deviceId)
|
|
|
|
// CPE reconnects on a NEW port — exact remoteKey lookup will miss
|
|
const result = manager.ensureSessionForRemote('10.0.0.20:2000', 'sess-fb1-new')
|
|
|
|
expect(result).toBe(true)
|
|
expect(manager.hasSession('sess-fb1-new')).toBe(true)
|
|
})
|
|
|
|
it('returns false when IP fallback is ambiguous (multiple RUNNING devices share the IP)', async () => {
|
|
const device1 = makeDevice()
|
|
const device2 = new (device1.constructor as any)(
|
|
new (Object.getPrototypeOf(device1.deviceId).constructor)(
|
|
'Sagemcom',
|
|
'AABBCC',
|
|
'Other',
|
|
'SN3'
|
|
)
|
|
)
|
|
await manager.startStateMachine(device1, 'sess-fb2a', { events: ['0 BOOTSTRAP'] })
|
|
await manager.startStateMachine(device2, 'sess-fb2b', { events: ['0 BOOTSTRAP'] })
|
|
manager.bindRemoteToDevice('10.0.0.21:1', device1.deviceId)
|
|
manager.bindRemoteToDevice('10.0.0.21:2', device2.deviceId)
|
|
|
|
const result = manager.ensureSessionForRemote('10.0.0.21:3000', 'sess-fb2-new')
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('returns false when the resolved device has no existing session', async () => {
|
|
const device = makeDevice()
|
|
manager.bindRemoteToDevice('10.0.0.22:1', device.deviceId)
|
|
// No startStateMachine call → no deviceSessions entry
|
|
const result = manager.ensureSessionForRemote('10.0.0.22:9999', 'sess-orphan')
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
it('preempts an ImmediateCommandStateMachine and rebinds when the incoming session differs', async () => {
|
|
const device = makeDevice()
|
|
// Start an Immediate machine on session-imm1
|
|
await manager.startStateMachine(device, 'session-imm1', { events: ['6 CONNECTION REQUEST'] })
|
|
|
|
// A BOOT event arrives on a DIFFERENT session while the immediate machine is still RUNNING
|
|
const preempted = await manager.startStateMachine(device, 'session-imm2', {
|
|
events: ['1 BOOT'],
|
|
})
|
|
|
|
// Preemption rebinds to the new session and swaps in a MainStateMachine
|
|
expect(preempted.session).toBe('session-imm2')
|
|
expect(manager.hasSession('session-imm2')).toBe(true)
|
|
expect(manager.hasSession('session-imm1')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('StateMachineManager — remote unbinding on completion', () => {
|
|
let manager: StateMachineManager
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
;(StateMachineManager as any).instance = null
|
|
manager = StateMachineManager.getInstance()
|
|
})
|
|
|
|
it('unbinds all remoteKeys for a device when its machine is completed via preemption', async () => {
|
|
const device = makeDevice()
|
|
await manager.startStateMachine(device, 'sess-unbind', { events: ['6 CONNECTION REQUEST'] })
|
|
manager.bindRemoteToDevice('10.1.1.1:100', device.deviceId)
|
|
manager.bindRemoteToDevice('10.1.1.1:200', device.deviceId)
|
|
|
|
expect(manager.getHashIdForRemote('10.1.1.1:100')).toBeDefined()
|
|
|
|
// A second CONNECTION REQUEST on a new session completes the first immediate
|
|
// machine (via the explicit preemption path), which triggers unbindRemotesForDevice.
|
|
await manager.startStateMachine(device, 'sess-unbind-2', { events: ['6 CONNECTION REQUEST'] })
|
|
|
|
expect(manager.getHashIdForRemote('10.1.1.1:100')).toBeUndefined()
|
|
expect(manager.getHashIdForRemote('10.1.1.1:200')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('StateMachineManager — continue()/handleCWMPResponse callback error paths', () => {
|
|
let manager: StateMachineManager
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
;(StateMachineManager as any).instance = null
|
|
manager = StateMachineManager.getInstance()
|
|
})
|
|
|
|
it('continue() logs but does not throw when the empty-body failure task callback rejects', async () => {
|
|
const device = makeDevice()
|
|
const machine = await manager.startStateMachine(device, 'sess-cberr', {
|
|
events: ['0 BOOTSTRAP'],
|
|
})
|
|
machine.lastTaskId = 'task-cb-err'
|
|
const taskQueue = manager.getTaskQueue()
|
|
;(taskQueue as any).tasks.set('task-cb-err', {
|
|
id: 'task-cb-err',
|
|
deviceId: device.deviceId,
|
|
type: 'get_parameter_values',
|
|
payload: [],
|
|
status: 'SENT',
|
|
createdAt: new Date(),
|
|
})
|
|
;(taskQueue as any).taskCallbacks.set(
|
|
'task-cb-err',
|
|
vi.fn().mockRejectedValue(new Error('callback boom'))
|
|
)
|
|
|
|
const response = await manager.continue('sess-cberr')
|
|
// Should not throw — error is caught and logged; state machine still advances
|
|
expect(typeof response).toBe('string')
|
|
})
|
|
|
|
it('handleCWMPResponse logs but does not throw when the task callback rejects', async () => {
|
|
const device = makeDevice()
|
|
const machine = await manager.startStateMachine(device, 'sess-hcberr', {
|
|
events: ['0 BOOTSTRAP'],
|
|
})
|
|
await manager.continue('sess-hcberr')
|
|
|
|
machine.lastTaskId = 'task-hcb-err'
|
|
const taskQueue = manager.getTaskQueue()
|
|
;(taskQueue as any).tasks.set('task-hcb-err', {
|
|
id: 'task-hcb-err',
|
|
deviceId: device.deviceId,
|
|
type: 'get_parameter_values',
|
|
payload: [],
|
|
status: 'SENT',
|
|
createdAt: new Date(),
|
|
})
|
|
;(taskQueue as any).taskCallbacks.set(
|
|
'task-hcb-err',
|
|
vi.fn().mockRejectedValue(new Error('callback boom'))
|
|
)
|
|
|
|
const response = await manager.handleCWMPResponse('sess-hcberr', true, {
|
|
ParameterList: { ParameterValueStruct: [] },
|
|
})
|
|
expect(typeof response).toBe('string')
|
|
})
|
|
|
|
it('continue() serves a pending queued task using its message builder', async () => {
|
|
const device = makeDevice()
|
|
await manager.startStateMachine(device, 'sess-queued', { events: ['2 PERIODIC'] })
|
|
// Drain the machine's own response first
|
|
await manager.continue('sess-queued')
|
|
|
|
// Manually enqueue an unrelated task for the device so continue() falls through
|
|
// to "serve queued tasks" branch when the state machine itself returns empty.
|
|
manager.getTaskQueue().addCWMPTask(device.deviceId, 'get_parameter_values', ['Device.Info.SW'])
|
|
|
|
const response = await manager.continue('sess-queued')
|
|
// Either the state machine already produced a response, or the queued task did.
|
|
expect(typeof response).toBe('string')
|
|
})
|
|
})
|