feat: TR-069 CWMP state machine hardening + exchange viewer improvements

- TR-069 CWMP state machine hardening (14 phases): shared event utility,
  multi-event routing, GPN circuit breaker, IdleState GPN guard, expanded
  GPN fault codes, CriticalReadsState, ConfigEval GPN guard, task queue
  race condition fix, explicit error handling, terminal states
  (OperationalState/PartialOperationalState), consolidated reclassify,
  crash pattern detection, removed enqueueImmediate, state flow wiring
- Fix SOAP Fault not processed by state machine (soap-env:Fault detection)
- Fix GPN circuit breaker not persisting after session drops
- Fix CriticalReadsState GPV response parsing (xsi:type normalization)
- Fix CriticalReadsState not processing GPV response values
- Skip SPV when device is already compliant (ProvisioningCode check)
- Add Device.DeviceInfo.ModelName to critical reads GPV
- Fix TypeError on DeviceDetail parameters view (null guards)
- Log empty 204 responses in exchange trace UI
- TR-181 catalog: type corrections and new entries (+6640 lines)
- SequenceViewer: replace session-nav buttons with compact dropdown select
- SequenceViewer: fix CPE/ACS lifeline headers wrapping to 2 lines
- Add 20 unit tests for state machine fixes (SOAP Fault, GPN circuit
  breaker, CriticalReads, stale session, ConfigEval/ComplianceUpdate skips)

Generated with [Continue](https://continue.dev)

Co-Authored-By: Continue <noreply@continue.dev>
This commit is contained in:
Kevin L'HereecandContinue committed 2026-06-08 12:49:00 +00:00
1 parent dabe15839d
commit aad93911e4
35 files changed
+8593 -4497

No files matched your search

@@ -0,0 +1,141 @@
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({}),
},
}))
// Mock ConfigurationModel
vi.mock('#models/configuration', () => ({
default: {
find: vi.fn().mockResolvedValue([]),
findByIdentifier: vi.fn().mockResolvedValue(null),
},
}))
// Mock firmware catalog service
vi.mock('../../../../app/services/firmware_catalog_service.js', () => ({
default: class {
async resolveUpgradePath() { return [] }
},
FirmwareCatalogService: class {
async resolveUpgradePath() { return [] }
},
}))
// Mock parameter_writability
vi.mock('../../../../app/services/parameter_writability.js', () => ({
reclassifyDeviceFromCatalog: vi.fn(),
}))
import { ConfigurationEvaluationState, ComplianceUpdateState } from '../../../../app/services/cwmp/configuration_evaluation_state.js'
import { Device } from '../../../../app/domain/device/device.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
import { DeviceProperty } from '../../../../app/domain/device/device_parameter.js'
import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js'
import { ParameterType } from '../../../../app/domain/shared/types.js'
import { buildProvisioningCode } from '../../../../app/utils/provisioning_code.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
}
function makeContext(device: Device) {
const taskQueue = new CWMPTaskQueue()
return {
device,
session: 'test-session',
taskQueue,
services: {
deviceAction: new DeviceActionExecutor(taskQueue),
},
}
}
describe('ConfigurationEvaluationState — skip inventory when already compliant', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('skips inventory when isAlreadyCompliant is true (no matching config)', async () => {
const device = makeDevice()
const context = makeContext(device)
const state = new ConfigurationEvaluationState()
const result = await state.handle(context as any)
// With no configs in DB, evaluate returns isAlreadyCompliant: true
// State should transition forward without sending any GPN/GPV
// (no inventory, no diffs)
expect(result.response).toBeFalsy()
expect(result.nextState || result.isComplete).toBeTruthy()
})
})
describe('ComplianceUpdateState — skip provCode SPV when matching', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('skips SPV when device already has matching provisioning code', async () => {
const device = makeDevice()
const mockConfig = { identifier: 'TestConfig', version: '1.0', desiredParameters: [], firmwareTarget: null }
const expectedProvCode = buildProvisioningCode(mockConfig as any)
// Set the device's ProvisioningCode to match what buildProvisioningCode produces
device.setProperty(new DeviceProperty(
'Device.DeviceInfo.ProvisioningCode',
expectedProvCode,
ParameterType.STRING
))
const context = makeContext(device)
const mockEngineResult = {
configuration: mockConfig,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
const state = new ComplianceUpdateState(mockEngineResult as any)
const result = await state.handle(context as any)
// Should NOT produce a response (no SPV sent)
// Should transition to ExecutingCommandsState
expect(result.response).toBeFalsy()
expect(result.nextState).toBeDefined()
})
})
@@ -0,0 +1,164 @@
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({}),
},
}))
// Mock configuration evaluation (skip config eval for these tests)
vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({
ConfigurationEvaluationState: class {
name = 'configuration-evaluation'
async handle() { return { isComplete: true, response: '' } }
},
}))
// Mock reclassify
vi.mock('../../../../app/services/parameter_writability.js', () => ({
reclassifyDeviceFromCatalog: vi.fn(),
}))
import { MainStateMachine } from '../../../../app/services/cwmp/main_state_machine.js'
import { Device } from '../../../../app/domain/device/device.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
function makeDevice(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
device.setGpnStatus('unsupported') // Skip GPN, go to CriticalReads
return device
}
describe('CriticalReadsState — GPV response processing', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('processes GPV response and updates device values', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start: IdleState → CriticalReadsState → sends GPV
const gpvRequest = await sm.start()
expect(gpvRequest).toContain('GetParameterValues')
// Simulate GPVResponse with ParameterValueStruct
await sm.handleResponse(true, {
ParameterList: {
ParameterValueStruct: [
{ Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v3.0', '$xsi:type': 'xsd:string' } },
{ Name: 'Device.DeviceInfo.SoftwareVersion', Value: { _: 'SR50-5.0.0', '$xsi:type': 'xsd:string' } },
{ Name: 'Device.DeviceInfo.ModelName', Value: { _: 'Livebox 4', '$xsi:type': 'xsd:string' } },
],
},
})
// Device should have the values stored
const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion')
expect(hwProp).not.toBeNull()
expect(hwProp!.value).toBe('v3.0')
const swProp = device.getProperty('Device.DeviceInfo.SoftwareVersion')
expect(swProp).not.toBeNull()
expect(swProp!.value).toBe('SR50-5.0.0')
})
it('normalizes Name fields from object to string when xsi:type attribute is present', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Simulate GPVResponse where Name has xsi:type attribute (parsed as object by XMLParser)
await sm.handleResponse(true, {
ParameterList: {
ParameterValueStruct: [
{
Name: { _: 'Device.DeviceInfo.HardwareVersion', '$xsi:type': 'xsd:string' },
Value: { _: 'v2.1', '$xsi:type': 'xsd:string' },
},
],
},
})
// Should normalize Name._ to string and store correctly
const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion')
expect(hwProp).not.toBeNull()
expect(hwProp!.value).toBe('v2.1')
})
it('handles empty ParameterValueStruct gracefully', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Simulate empty GPV response
await sm.handleResponse(true, { ParameterList: { ParameterValueStruct: [] } })
// Should not throw, machine should continue
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
})
it('handles missing ParameterList gracefully', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Simulate response with no ParameterList
await sm.handleResponse(true, {})
// Should not throw, machine should continue
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
})
it('transitions to configuration-evaluation after processing', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
await sm.handleResponse(true, {
ParameterList: {
ParameterValueStruct: [
{ Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v1', '$xsi:type': 'xsd:string' } },
],
},
})
// Should have transitioned past critical-reads to configuration-evaluation
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
})
})
@@ -0,0 +1,132 @@
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({}),
},
}))
// Mock configuration engine
vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({
ConfigurationEvaluationState: class {
name = 'configuration-evaluation'
async handle() { return { isComplete: true, response: '' } }
},
}))
// Mock reclassify
vi.mock('../../../../app/services/parameter_writability.js', () => ({
reclassifyDeviceFromCatalog: vi.fn(),
}))
import { MainStateMachine } from '../../../../app/services/cwmp/main_state_machine.js'
import { Device } from '../../../../app/domain/device/device.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
import DeviceModel from '../../../../app/models/device.js'
function makeDevice(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
return device
}
describe('GPN Circuit Breaker — DiscoveringParametersState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('IdleState skips GPN when gpnStatus is unsupported', async () => {
const device = makeDevice()
device.setGpnStatus('unsupported')
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start triggers IdleState → should skip GPN
const response = await sm.start()
// Should produce a GPV (critical reads) not a GPN
expect(response).toContain('GetParameterValues')
expect(response).not.toContain('GetParameterNames')
})
it('IdleState skips GPN when circuit breaker is open', async () => {
const device = makeDevice()
device.setGpnStatus('unknown')
device.setGpnCircuitBreaker({
failureCount: 2,
lastFailureDate: new Date(),
state: 'open',
cooldownUntil: new Date(Date.now() + 86400000),
})
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
const response = await sm.start()
// Should produce a GPV (critical reads) not a GPN
expect(response).toContain('GetParameterValues')
expect(response).not.toContain('GetParameterNames')
})
it('IdleState proceeds to GPN when gpnStatus is unknown and circuit breaker is closed', async () => {
const device = makeDevice()
device.setGpnStatus('unknown')
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
const response = await sm.start()
// Should produce a GPN request
expect(response).toContain('GetParameterNames')
})
it('DiscoveringParametersState calls recordGpnFailure and persists on fault', async () => {
const device = makeDevice()
device.setGpnStatus('unknown')
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start: IdleState → DiscoveringParametersState → sends GPN
await sm.start()
// Simulate CPE fault response
const response = await sm.handleResponse(false, { FaultCode: '9001', FaultString: 'Method not supported' })
// Device should now be marked as unsupported
expect(device.gpnStatus).toBe('unsupported')
// Circuit breaker should have recorded the failure
expect(device.gpnCircuitBreaker.failureCount).toBe(1)
// Should have persisted via createOrUpdate
expect(DeviceModel.createOrUpdate).toHaveBeenCalled()
// Should continue to critical reads (GPV)
expect(response).toContain('GetParameterValues')
})
})
@@ -13,8 +13,8 @@ vi.mock('../../../../app/models/command.js', () => ({
default: {
getCollection: () => ({
find: vi.fn().mockReturnValue({
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
}),
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
}),
}),
updateCommand: vi.fn().mockResolvedValue({}),
},
@@ -54,17 +54,19 @@ describe('MainStateMachine', () => {
await sm.start()
// Simulate GPN success response
const response = await sm.handleResponse(true, {
ParameterList: { ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }] },
ParameterList: {
ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }],
},
})
// After GPN success, should transition to configuration-evaluation
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
// After GPN success, should transition to critical-reads
expect(sm.getCurrentStateName()).toBe('critical-reads')
})
it('with unknown data model, skips to configuration-evaluation', async () => {
it('with unknown data model, skips to critical-reads', async () => {
const sm = new MainStateMachine(makeDevice('unknown'), 'sess-1', new CWMPTaskQueue())
await sm.start()
// idle → directly to configuration-evaluation (no GPN for unknown model)
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
// idle → directly to critical-reads (no GPN for unknown model)
expect(sm.getCurrentStateName()).toBe('critical-reads')
})
it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => {
@@ -82,23 +84,24 @@ describe('MainStateMachine', () => {
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
})
it('full lifecycle: idle → discovering → config-eval → compliance → executing → completed', async () => {
it('full lifecycle: idle → discovering → critical-reads → config-eval → compliance → executing → operational', async () => {
const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue())
// Step 1: start → GPN
await sm.start()
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
// Step 2: GPN response → config-eval
// Step 2: GPN response → critical-reads
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
expect(sm.getCurrentStateName()).toBe('critical-reads')
// Step 3+: config eval will process its queue and eventually reach compliance-update
// Since our mock engine returns inventoryPaths, it will send GPN requests
// After all config eval actions complete, it reaches compliance-update → executing-commands → completed
// Simulate multiple responses to drain the config eval state
// Step 3+: critical reads will send GPV, after response moves to config eval
// Simulate multiple responses to drain the state machine
let guard = 0
while (!sm.isCompleted() && guard++ < 20) {
await sm.handleResponse(true, { Status: 0, ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] } })
await sm.handleResponse(true, {
Status: 0,
ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] },
})
}
expect(sm.isCompleted()).toBe(true)
})
@@ -127,7 +130,9 @@ describe('ExecutingCommandsState', () => {
services: { deviceAction: { getParameterValues: vi.fn() } as any },
}
const result = await state.handle(context)
expect(result.isComplete).toBe(true)
// With includeNI=true and no commands, transitions to OperationalState
expect(result.nextState).toBeDefined()
expect(result.nextState!.name).toBe('operational')
})
it('returns isComplete when no commands found (includeNI=false)', async () => {
@@ -0,0 +1,100 @@
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'
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 — 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()
})
})
+8 -7
View File
@@ -36,21 +36,22 @@ describe('CWMPTaskQueue', () => {
describe('addTask', () => {
it('adds task, marks as SENT, and returns SOAP XML', () => {
const xml = queue.addTask(makeDeviceId(), 'reboot', { commandKey: 'test' }, async () => '')
expect(xml).toContain('Reboot')
expect(xml).toContain('test')
const result = queue.addTask(makeDeviceId(), 'reboot', { commandKey: 'test' }, async () => '')
expect(result.xml).toContain('Reboot')
expect(result.xml).toContain('test')
expect(result.taskId).toBeDefined()
})
it('returns empty string for unknown task type', () => {
const xml = queue.addTask(makeDeviceId(), 'unknown_type', {}, async () => '')
expect(xml).toBe('')
const result = queue.addTask(makeDeviceId(), 'unknown_type', {}, async () => '')
expect(result.xml).toBe('')
})
it('emits taskSent event', () => {
it('does not emit taskSent event (race condition fix)', () => {
let sent = false
queue.on('taskSent', () => { sent = true })
queue.addTask(makeDeviceId(), 'factory_reset', {}, async () => '')
expect(sent).toBe(true)
expect(sent).toBe(false)
})
})
@@ -102,7 +102,7 @@ describe('MainStateMachine — TR-181 device', () => {
// Simulate any response (success or failure) — state machine advances
await sm.handleResponse(true, {})
// Should have moved past discovering-parameters (no more paths in queue)
// Should have moved past discovering-parameters (now in critical-reads)
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
expect(sm.getCurrentStateName()).not.toBe('idle')
})
@@ -198,16 +198,17 @@ describe('MainStateMachine — TR-098 device', () => {
// ---------------------------------------------------------------------------
describe('MainStateMachine — unknown data model', () => {
it('skips GPN and goes directly to configuration-evaluation', async () => {
it('skips GPN and goes directly to critical-reads', async () => {
const device = makeUnknownDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Should skip discovering-parameters entirely
// Should skip discovering-parameters and go to critical-reads
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
expect(sm.getCurrentStateName()).not.toBe('idle')
expect(sm.getCurrentStateName()).toBe('critical-reads')
})
})
@@ -254,7 +255,7 @@ describe('MainStateMachine — CompletedState reclassification', () => {
// ---------------------------------------------------------------------------
describe('DeviceActionExecutor — reclassification after updateFromValues', () => {
it('reclassifies properties added via SPV callback when GPN unsupported', async () => {
it('SPV callback updates device values but does not reclassify (consolidated to terminal state)', async () => {
const device = makeTr181Device()
device.setGpnStatus('unsupported')
// Device starts with only read-only properties (like after first Inform)
@@ -282,10 +283,9 @@ describe('DeviceActionExecutor — reclassification after updateFromValues', ()
await taskQueue.executeTaskCallback(spvTask!.id, 'success' as any, { Status: 0 })
// ProvisioningCode should now be a parameter (reclassified after updateFromValues)
expect(device.getParameter('Device.DeviceInfo.ProvisioningCode')).not.toBeNull()
expect(device.getParameter('Device.DeviceInfo.ProvisioningCode')!.value).toBe('test-code')
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')).toBeNull()
// ProvisioningCode is stored as a property (reclassification happens later in OperationalState)
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')).not.toBeNull()
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')!.value).toBe('test-code')
})
it('does NOT reclassify when GPN is supported (live data is authoritative)', async () => {