Merge branch 'master' into atx-result-staging-20260608_080351_ed7fbc81
# Conflicts: # tests/unit/services/cwmp/config_eval_extra.spec.ts # tests/unit/services/cwmp/main_state_machine_extended.spec.ts # tests/unit/services/cwmp/main_state_machine_extra.spec.ts # tests/unit/services/cwmp/task_queue.spec.ts
This commit is contained in:
commit
d0a4f8fb30
14 files changed
+386
-31
No files matched your search
@@ -131,6 +131,10 @@ export default class CwmpController {
|
||||
const paramValues = informData?.ParameterList?.ParameterValueStruct || []
|
||||
if (paramValues.length > 0) {
|
||||
device.updateFromValues(paramValues)
|
||||
// Some writable parameters arrive in the Inform before GPN has classified them.
|
||||
// updateFromValues falls back to storing unknowns as read-only properties.
|
||||
// Reclassify known-writable Inform parameters as parameters (writable).
|
||||
device.reclassifyInformWritables(paramValues)
|
||||
}
|
||||
|
||||
device.setTmpParamValues(paramValues)
|
||||
|
||||
@@ -251,6 +251,37 @@ export class Device {
|
||||
}
|
||||
}
|
||||
|
||||
public removeProperty(name: string): void {
|
||||
this._properties.delete(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters that arrive in the Inform payload are writable by definition
|
||||
* (the CPE reports them because the ACS is supposed to manage them).
|
||||
* updateFromValues() defaults unknowns to properties (read-only), so we
|
||||
* promote the known-writable Inform params back to parameters here.
|
||||
*
|
||||
* Known writable Inform parameters (TR-098 and TR-181):
|
||||
* - *.ProvisioningCode
|
||||
* - *.ParameterKey
|
||||
*/
|
||||
public reclassifyInformWritables(paramValues: any[]): void {
|
||||
const WRITABLE_PATTERNS = [
|
||||
/(^|\.)ProvisioningCode$/,
|
||||
/(^|\.)ParameterKey$/,
|
||||
]
|
||||
for (const pv of paramValues || []) {
|
||||
const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || null
|
||||
if (!name) continue
|
||||
if (!WRITABLE_PATTERNS.some((re) => re.test(name))) continue
|
||||
// Only act if it ended up as a property (wrong classification)
|
||||
if (!this._properties.has(name)) continue
|
||||
const prop = this._properties.get(name)!
|
||||
this._properties.delete(name)
|
||||
this._parameters.set(name, new DeviceParameter(name, prop.value, prop.type))
|
||||
}
|
||||
}
|
||||
|
||||
public getConnectionRequestURL(): URL | null {
|
||||
for (const property of this._properties.values()) {
|
||||
const url = property.getConnectionRequestURL()
|
||||
|
||||
@@ -131,7 +131,20 @@ export class ConfigurationEvaluationState {
|
||||
const response = await context.services.deviceAction.getParameterValues(
|
||||
context.device,
|
||||
leaves,
|
||||
() => Promise.resolve('')
|
||||
async (result) => {
|
||||
if (result.status === 'success' && result.data) {
|
||||
const list = result.data?.ParameterList?.ParameterValueStruct
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
const normalized = list.map((pv: any) => ({
|
||||
...pv,
|
||||
Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name,
|
||||
}))
|
||||
context.device.updateFromValues(normalized)
|
||||
logger.debug({ identifier: context.device.identifier, count: normalized.length }, '[ConfigEval] Inventory GPV values saved to device')
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
)
|
||||
return { response }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Device } from '../../domain/device/device.js'
|
||||
import { DeviceParameter } from '../../domain/device/device_parameter.js'
|
||||
import { ParameterType } from '../../domain/shared/types.js'
|
||||
import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js'
|
||||
import DeviceModel from '../../models/device.js'
|
||||
@@ -401,7 +402,10 @@ export class DeviceActionExecutor {
|
||||
{ parameterValueList: pvStructs, parameterKey },
|
||||
async (result: TaskResult) => {
|
||||
if (result.status === ResultStatus.Success) {
|
||||
device.updateFromValues(pvStructs as any)
|
||||
// ProvisioningCode must always be stored as a writable parameter.
|
||||
// If it was previously classified as a read-only property (wrong), remove it first.
|
||||
device.removeProperty(provisioningPath)
|
||||
device.setParameter(new DeviceParameter(provisioningPath, device.tmpPC, ParameterType.STRING))
|
||||
await this.persistDevice(device)
|
||||
device.tmpPC = ''
|
||||
return await callback(true)
|
||||
|
||||
@@ -59,14 +59,29 @@ class IdleState extends DeviceState {
|
||||
'[IdleState] GPN allowed — proceeding to discovery'
|
||||
)
|
||||
|
||||
// Test GPN support using the ManagementServer object path
|
||||
// GPN is supported: discover mandatory subtrees to classify parameters.
|
||||
// DeviceInfo. is always included so HardwareVersion, SoftwareVersion, ModelName etc.
|
||||
// are classified (writable vs read-only) from the device itself.
|
||||
// If GPN is not supported, the catalog handles classification (reclassifyDeviceFromCatalog).
|
||||
const dataModel = device.dataModel
|
||||
if (dataModel === 'tr-181') {
|
||||
return { nextState: new DiscoveringParametersState(['Device.ManagementServer.']), response: '' }
|
||||
return {
|
||||
nextState: new DiscoveringParametersState([
|
||||
'Device.DeviceInfo.',
|
||||
'Device.ManagementServer.',
|
||||
]),
|
||||
response: '',
|
||||
}
|
||||
} else if (dataModel === 'tr-098') {
|
||||
return { nextState: new DiscoveringParametersState(['InternetGatewayDevice.ManagementServer.']), response: '' }
|
||||
return {
|
||||
nextState: new DiscoveringParametersState([
|
||||
'InternetGatewayDevice.DeviceInfo.',
|
||||
'InternetGatewayDevice.ManagementServer.',
|
||||
]),
|
||||
response: '',
|
||||
}
|
||||
}
|
||||
// Unknown data model — skip GPN test, go to CriticalReadsState
|
||||
// Unknown data model — skip GPN, go to CriticalReadsState
|
||||
return { nextState: new CriticalReadsState(), response: '' }
|
||||
}
|
||||
}
|
||||
@@ -130,7 +145,21 @@ class DiscoveringParametersState extends DeviceState {
|
||||
return { nextState: new CriticalReadsState(), response: '' }
|
||||
}
|
||||
|
||||
// Process response and continue
|
||||
// GPN success — process the response
|
||||
if (context.message?.success && context.message.data) {
|
||||
try {
|
||||
context.device.organizeParameters(context.message.data, new Date())
|
||||
context.device.setGpnStatus('supported')
|
||||
logger.info(
|
||||
{ deviceId: context.device.identifier, paramCount: context.device.getParameters().length },
|
||||
'[DiscoveringParameters] GPN response processed'
|
||||
)
|
||||
} catch (e: any) {
|
||||
logger.error({ error: e?.message }, '[DiscoveringParameters] Error processing GPN response')
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with next path in queue if any
|
||||
if (this.parameterQueue.length > 0) {
|
||||
return { response: await this.processNext(context) }
|
||||
}
|
||||
|
||||
@@ -343,8 +343,13 @@ export class StateMachineManager extends EventEmitter {
|
||||
return ''
|
||||
}
|
||||
|
||||
// If state machine produced a response, send it
|
||||
// If state machine produced a response, send it.
|
||||
// Also track the task ID that was just created (by addTask inside a state handler)
|
||||
// so that handleCWMPResponse can execute its callback when the CPE replies.
|
||||
if (response && response !== '') {
|
||||
if (!machineInstance.lastTaskId) {
|
||||
machineInstance.lastTaskId = this.taskQueue.getLastCreatedTaskId(machineInstance.deviceId)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -409,6 +414,12 @@ export class StateMachineManager extends EventEmitter {
|
||||
return ''
|
||||
}
|
||||
|
||||
// If the state machine produced a new request (e.g. next inventory GPN, next SPV),
|
||||
// track the task ID so the next handleCWMPResponse can fire its callback.
|
||||
if (response && response !== '' && !machineInstance.lastTaskId) {
|
||||
machineInstance.lastTaskId = this.taskQueue.getLastCreatedTaskId(machineInstance.deviceId)
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`Error handling CWMP response: ${error}`)
|
||||
@@ -434,6 +445,8 @@ export class StateMachineManager extends EventEmitter {
|
||||
const deviceKey = JSON.stringify(machine.deviceId)
|
||||
this.deviceSessions.delete(deviceKey)
|
||||
this.sessionMachines.delete(machine.session)
|
||||
// Drain any leftover tasks so they don't bleed into the next session
|
||||
this.taskQueue.clearPendingTasksForDevice(machine.deviceId)
|
||||
}
|
||||
|
||||
private failMachine(machine: StateMachineInstance, error: string): void {
|
||||
|
||||
@@ -86,6 +86,8 @@ export class CWMPTaskQueue extends EventEmitter {
|
||||
private tasks: Map<string, CWMPTask> = new Map()
|
||||
private deviceQueues: Map<string, string[]> = new Map()
|
||||
private taskCallbacks: Map<string, (result: TaskResult) => Promise<string>> = new Map()
|
||||
/** Tracks the most recently created task per device (set by addTask) */
|
||||
private lastCreatedTaskIdByDevice: Map<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* Adds a task with callback (legacy-compatible API used by DeviceActionExecutor)
|
||||
@@ -108,6 +110,9 @@ export class CWMPTaskQueue extends EventEmitter {
|
||||
cwmpTask.status = TaskStatus.SENT
|
||||
}
|
||||
|
||||
// Track the most recent task created for this device so callers can retrieve its ID
|
||||
this.lastCreatedTaskIdByDevice.set(JSON.stringify(deviceId), cwmpTask.id)
|
||||
|
||||
// Generate CWMP envelope
|
||||
const builder = CWMPTaskQueue.task_map[taskType]
|
||||
if (typeof builder !== 'function') {
|
||||
@@ -217,20 +222,28 @@ export class CWMPTaskQueue extends EventEmitter {
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of the most recently created task for a device (via addTask).
|
||||
* Used by the state machine manager to correlate responses with in-flight tasks
|
||||
* that were created directly by state handlers (not served from the external queue).
|
||||
*/
|
||||
getLastCreatedTaskId(deviceId: DeviceId): string | undefined {
|
||||
return this.lastCreatedTaskIdByDevice.get(JSON.stringify(deviceId))
|
||||
}
|
||||
|
||||
clearPendingTasksForDevice(deviceId: DeviceId): void {
|
||||
const deviceKey = JSON.stringify(deviceId)
|
||||
const queue = this.deviceQueues.get(deviceKey) || []
|
||||
const keep: string[] = []
|
||||
// Clear ALL tasks (PENDING and SENT) — after session completion, no task
|
||||
// from the previous session should bleed into the next one. Tasks that were
|
||||
// already SENT (created by state-machine states via addTask()) are ghost
|
||||
// entries that would otherwise be served to the next session.
|
||||
for (const taskId of queue) {
|
||||
const t = this.tasks.get(taskId)
|
||||
if (t && t.status === TaskStatus.SENT) {
|
||||
keep.push(taskId)
|
||||
continue
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
this.taskCallbacks.delete(taskId)
|
||||
}
|
||||
this.deviceQueues.set(deviceKey, keep)
|
||||
this.deviceQueues.set(deviceKey, [])
|
||||
this.lastCreatedTaskIdByDevice.delete(deviceKey)
|
||||
}
|
||||
|
||||
private generateTaskId(deviceId: DeviceId, type: string): string {
|
||||
|
||||
@@ -226,3 +226,67 @@ describe('Device', () => {
|
||||
expect(d.getParameter('Device.X')!.value).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device.reclassifyInformWritables', () => {
|
||||
it('promotes ProvisioningCode from property to parameter', () => {
|
||||
const d = makeDevice()
|
||||
// Simulate what updateFromValues does on Inform: stores as property (unknown writability)
|
||||
d.setProperty(new DeviceProperty('InternetGatewayDevice.DeviceInfo.ProvisioningCode', 'abc'))
|
||||
|
||||
d.reclassifyInformWritables([
|
||||
{ Name: 'InternetGatewayDevice.DeviceInfo.ProvisioningCode', Value: { _: 'abc' } },
|
||||
])
|
||||
|
||||
expect(d.getParameter('InternetGatewayDevice.DeviceInfo.ProvisioningCode')).not.toBeNull()
|
||||
expect(d.getProperty('InternetGatewayDevice.DeviceInfo.ProvisioningCode')).toBeNull()
|
||||
})
|
||||
|
||||
it('promotes ParameterKey from property to parameter', () => {
|
||||
const d = makeDevice()
|
||||
d.setProperty(new DeviceProperty('InternetGatewayDevice.ManagementServer.ParameterKey', ''))
|
||||
|
||||
d.reclassifyInformWritables([
|
||||
{ Name: 'InternetGatewayDevice.ManagementServer.ParameterKey', Value: { _: '' } },
|
||||
])
|
||||
|
||||
expect(d.getParameter('InternetGatewayDevice.ManagementServer.ParameterKey')).not.toBeNull()
|
||||
expect(d.getProperty('InternetGatewayDevice.ManagementServer.ParameterKey')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not touch params that are already parameters', () => {
|
||||
const d = makeDevice()
|
||||
d.setParameter(new DeviceParameter('Device.DeviceInfo.ProvisioningCode', 'existing'))
|
||||
|
||||
d.reclassifyInformWritables([
|
||||
{ Name: 'Device.DeviceInfo.ProvisioningCode', Value: { _: 'existing' } },
|
||||
])
|
||||
|
||||
expect(d.getParameter('Device.DeviceInfo.ProvisioningCode')!.value).toBe('existing')
|
||||
expect(d.getProperty('Device.DeviceInfo.ProvisioningCode')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not reclassify unknown names (e.g. SoftwareVersion stays property)', () => {
|
||||
const d = makeDevice()
|
||||
d.setProperty(new DeviceProperty('InternetGatewayDevice.DeviceInfo.SoftwareVersion', 'v1'))
|
||||
|
||||
d.reclassifyInformWritables([
|
||||
{ Name: 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', Value: { _: 'v1' } },
|
||||
])
|
||||
|
||||
// SoftwareVersion is NOT in the writable list — should stay as property
|
||||
expect(d.getProperty('InternetGatewayDevice.DeviceInfo.SoftwareVersion')).not.toBeNull()
|
||||
expect(d.getParameter('InternetGatewayDevice.DeviceInfo.SoftwareVersion')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles Name as object node (XMLParser artefact)', () => {
|
||||
const d = makeDevice()
|
||||
d.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'code'))
|
||||
|
||||
d.reclassifyInformWritables([
|
||||
{ Name: { _: 'Device.DeviceInfo.ProvisioningCode' }, Value: { _: 'code' } },
|
||||
])
|
||||
|
||||
expect(d.getParameter('Device.DeviceInfo.ProvisioningCode')).not.toBeNull()
|
||||
expect(d.getProperty('Device.DeviceInfo.ProvisioningCode')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -502,6 +502,69 @@ describe('FirmwareUpgradeState — download callback', () => {
|
||||
})
|
||||
|
||||
|
||||
describe('ConfigurationEvaluationState — inventory phase 2 GPV saves values on device', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('GPV callback in phase 2 calls updateFromValues so parameters are persisted', async () => {
|
||||
const config = makeConfig({
|
||||
desiredParameters: [{ name: 'Device.X', value: '1', type: 'string' }],
|
||||
inventory: [{ path: 'Device.ManagementServer.' }],
|
||||
})
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
|
||||
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('supported')
|
||||
const context = makeContext(device)
|
||||
|
||||
const state = new ConfigurationEvaluationState()
|
||||
|
||||
// Step 1: first handle → sends GPN for inventory
|
||||
await state.handle(context as any)
|
||||
|
||||
// Step 2: execute GPN callback with two leaf names
|
||||
const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map<string, any>
|
||||
const gpnTaskId = Array.from(taskCallbacks.keys())[0]
|
||||
await context.taskQueue.executeTaskCallback(gpnTaskId!, ResultStatus.Success, {
|
||||
ParameterList: {
|
||||
ParameterInfoStruct: [
|
||||
{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' },
|
||||
{ Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: '0' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Step 3: second handle → sends GPV for discovered leaves (phase 2)
|
||||
const result2 = await state.handle(context as any)
|
||||
expect(result2.response).toContain('GetParameterValues')
|
||||
|
||||
// Before GPV callback — device has no value for PeriodicInformInterval
|
||||
const before = device.getParameters().find((p) => p.name === 'Device.ManagementServer.PeriodicInformInterval')
|
||||
expect(before?.value ?? null).toBeNull()
|
||||
|
||||
// Step 4: execute GPV callback with actual values
|
||||
const gpvTaskId = Array.from(taskCallbacks.keys()).pop()
|
||||
await context.taskQueue.executeTaskCallback(gpvTaskId!, ResultStatus.Success, {
|
||||
ParameterList: {
|
||||
ParameterValueStruct: [
|
||||
{ Name: 'Device.ManagementServer.PeriodicInformInterval', Value: { _: '3600', $: { 'xsi:type': 'xsd:unsignedInt' } } },
|
||||
{ Name: 'Device.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/cr', $: { 'xsi:type': 'xsd:string' } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// After GPV callback — device must have the values saved
|
||||
const after = device.getParameters().find((p) => p.name === 'Device.ManagementServer.PeriodicInformInterval')
|
||||
expect(after).toBeDefined()
|
||||
expect(after!.value).toBe('3600')
|
||||
|
||||
const afterURL = device.getParameters().find((p) => p.name === 'Device.ManagementServer.ConnectionRequestURL')
|
||||
expect(afterURL).toBeDefined()
|
||||
expect(afterURL!.value).toBe('http://cpe/cr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigurationEvaluationState — firmware upgrade transition (lines 154-158)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js'
|
||||
import { CWMPTaskQueue, ResultStatus, TaskResult } from '../../../../app/services/cwmp/task_queue.js'
|
||||
import { CWMPTaskQueue, ResultStatus } from '../../../../app/services/cwmp/task_queue.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 { ParameterType } from '../../../../app/domain/shared/types.js'
|
||||
|
||||
// Mock AdonisJS logger
|
||||
vi.mock('@adonisjs/core/services/logger', () => ({
|
||||
@@ -139,6 +141,44 @@ describe('DeviceActionExecutor', () => {
|
||||
expect(xml).toContain('SetParameterValues')
|
||||
expect(xml).toContain('ProvisioningCode')
|
||||
})
|
||||
|
||||
it('stores ProvisioningCode as a writable parameter (not property) after SPV success', async () => {
|
||||
const device = makeDevice()
|
||||
device.setDataModel('tr-181')
|
||||
device.tmpPC = 'code-xyz'
|
||||
|
||||
// Device has no prior ProvisioningCode entry
|
||||
await executor.insertProvisioningCode(device, async () => '')
|
||||
const task = queue.getTaskForDevice(device.deviceId)
|
||||
await queue.executeTaskCallback(task!.id, ResultStatus.Success, {})
|
||||
|
||||
// Must be in parameters (writable), not properties (read-only)
|
||||
const asParam = device.getParameter('Device.DeviceInfo.ProvisioningCode')
|
||||
const asProp = device.getProperty('Device.DeviceInfo.ProvisioningCode')
|
||||
expect(asParam).not.toBeNull()
|
||||
expect(asProp).toBeNull()
|
||||
expect(asParam!.value).toBe('code-xyz')
|
||||
})
|
||||
|
||||
it('keeps ProvisioningCode as parameter when it already exists as property (reclassifies)', async () => {
|
||||
const device = makeDevice()
|
||||
device.setDataModel('tr-181')
|
||||
device.tmpPC = 'new-code'
|
||||
|
||||
// Simulate device where ProvisioningCode was incorrectly stored as property
|
||||
device.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'old-code', ParameterType.STRING))
|
||||
|
||||
await executor.insertProvisioningCode(device, async () => '')
|
||||
const task = queue.getTaskForDevice(device.deviceId)
|
||||
await queue.executeTaskCallback(task!.id, ResultStatus.Success, {})
|
||||
|
||||
// After SPV success, must be a parameter, not a property
|
||||
const asParam = device.getParameter('Device.DeviceInfo.ProvisioningCode')
|
||||
const asProp = device.getProperty('Device.DeviceInfo.ProvisioningCode')
|
||||
expect(asParam).not.toBeNull()
|
||||
expect(asProp).toBeNull()
|
||||
expect(asParam!.value).toBe('new-code')
|
||||
})
|
||||
})
|
||||
|
||||
describe('callbacks', () => {
|
||||
|
||||
@@ -105,6 +105,77 @@ describe('GPN Circuit Breaker — DiscoveringParametersState', () => {
|
||||
expect(response).toContain('GetParameterNames')
|
||||
})
|
||||
|
||||
it('DiscoveringParametersState processes GPN response and stores parameters', async () => {
|
||||
const device = makeDevice() // tr-181
|
||||
device.setGpnStatus('unknown')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
// Start: IdleState → DiscoveringParametersState → sends GPN for Device.DeviceInfo.
|
||||
await sm.start()
|
||||
|
||||
// Simulate a successful GPN response for DeviceInfo
|
||||
const gpnResponse = {
|
||||
ParameterList: {
|
||||
ParameterInfoStruct: [
|
||||
{ Name: 'Device.DeviceInfo.', Writable: false },
|
||||
{ Name: 'Device.DeviceInfo.HardwareVersion', Writable: false },
|
||||
{ Name: 'Device.DeviceInfo.SoftwareVersion', Writable: false },
|
||||
{ Name: 'Device.DeviceInfo.ModelName', Writable: false },
|
||||
{ Name: 'Device.DeviceInfo.ProvisioningCode', Writable: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
// First GPN response → still discovering (ManagementServer. still in queue)
|
||||
await sm.handleResponse(true, gpnResponse)
|
||||
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
||||
|
||||
// Second GPN response (for Device.ManagementServer.) → transitions to critical-reads
|
||||
const gpnResponse2 = {
|
||||
ParameterList: {
|
||||
ParameterInfoStruct: [
|
||||
{ Name: 'Device.ManagementServer.', Writable: false },
|
||||
{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: true },
|
||||
{ Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: false },
|
||||
],
|
||||
},
|
||||
}
|
||||
await sm.handleResponse(true, gpnResponse2)
|
||||
|
||||
// gpnStatus should be 'supported'
|
||||
expect(device.gpnStatus).toBe('supported')
|
||||
|
||||
// Writable params should be stored as parameters
|
||||
const periodicInterval = device.getParameter('Device.ManagementServer.PeriodicInformInterval')
|
||||
expect(periodicInterval).not.toBeNull()
|
||||
|
||||
// Read-only params should be stored as properties
|
||||
const connUrl = device.getProperty('Device.ManagementServer.ConnectionRequestURL')
|
||||
expect(connUrl).not.toBeNull()
|
||||
|
||||
// Writable DeviceInfo param should be parameter
|
||||
const provCode = device.getParameter('Device.DeviceInfo.ProvisioningCode')
|
||||
expect(provCode).not.toBeNull()
|
||||
|
||||
// Object paths (ending with dot) should NOT be stored
|
||||
const obj = device.getParameter('Device.DeviceInfo.')
|
||||
expect(obj).toBeNull()
|
||||
})
|
||||
|
||||
it('DiscoveringParametersState sets gpnStatus supported even with empty param list', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
await sm.start()
|
||||
|
||||
// Respond with empty list
|
||||
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
||||
|
||||
expect(device.gpnStatus).toBe('supported')
|
||||
})
|
||||
|
||||
it('DiscoveringParametersState calls recordGpnFailure and persists on fault', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
|
||||
@@ -44,22 +44,23 @@ describe('MainStateMachine', () => {
|
||||
it('start triggers state execution and returns response', async () => {
|
||||
const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue())
|
||||
const response = await sm.start()
|
||||
// idle → discovering-parameters → sends GPN for Device.ManagementServer.
|
||||
// idle → discovering-parameters → sends GPN for Device.DeviceInfo. (first mandatory subtree)
|
||||
expect(response).toContain('GetParameterNames')
|
||||
expect(response).toContain('Device.DeviceInfo.')
|
||||
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
||||
})
|
||||
|
||||
it('handleResponse advances the state machine', async () => {
|
||||
const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue())
|
||||
await sm.start()
|
||||
// Simulate GPN success response
|
||||
// Simulate GPN success response for DeviceInfo (first path)
|
||||
await sm.handleResponse(true, {
|
||||
ParameterList: {
|
||||
ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }],
|
||||
ParameterInfoStruct: [{ Name: 'Device.DeviceInfo.HardwareVersion', Writable: '0' }],
|
||||
},
|
||||
})
|
||||
// After GPN success, should transition to critical-reads
|
||||
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
||||
// Queue still has ManagementServer. → still in discovering-parameters
|
||||
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
||||
})
|
||||
|
||||
it('with unknown data model, skips to critical-reads', async () => {
|
||||
@@ -72,7 +73,7 @@ describe('MainStateMachine', () => {
|
||||
it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => {
|
||||
const sm = new MainStateMachine(makeDevice('tr-098'), 'sess-1', new CWMPTaskQueue())
|
||||
const response = await sm.start()
|
||||
expect(response).toContain('InternetGatewayDevice.ManagementServer.')
|
||||
expect(response).toContain('InternetGatewayDevice.DeviceInfo.')
|
||||
})
|
||||
|
||||
it('GPN failure sets gpnStatus unsupported and continues', async () => {
|
||||
@@ -90,7 +91,11 @@ describe('MainStateMachine', () => {
|
||||
await sm.start()
|
||||
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
||||
|
||||
// Step 2: GPN response → critical-reads
|
||||
// Step 2a: first GPN response (DeviceInfo.) → still discovering
|
||||
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
||||
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
||||
|
||||
// Step 2b: second GPN response (ManagementServer.) → critical-reads
|
||||
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
||||
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN f
|
||||
|
||||
const response = await sm.start()
|
||||
expect(response).toContain('GetParameterNames')
|
||||
expect(response).toContain('InternetGatewayDevice.ManagementServer.')
|
||||
expect(response).toContain('InternetGatewayDevice.DeviceInfo.')
|
||||
})
|
||||
|
||||
it('skips GPN for unknown dataModel and goes to critical-reads', async () => {
|
||||
@@ -83,17 +83,19 @@ describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN f
|
||||
})
|
||||
|
||||
it('DiscoveringParametersState handles GPN success and transitions to CriticalReads', async () => {
|
||||
const device = makeDevice('unknown')
|
||||
const device = makeDevice('unknown') // tr-181 → DeviceInfo. + ManagementServer. in queue
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
||||
|
||||
// Start: sends GPN
|
||||
// Start: sends GPN for Device.DeviceInfo.
|
||||
await sm.start()
|
||||
|
||||
// Simulate successful GPN response (empty parameter list)
|
||||
const response = await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
||||
// First GPN response (DeviceInfo) → still in discovering-parameters (ManagementServer. pending)
|
||||
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
||||
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
||||
|
||||
// Should transition to critical-reads and send GPV
|
||||
// Second GPN response (ManagementServer) → transitions to critical-reads and sends GPV
|
||||
const response = await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
||||
expect(response).toContain('GetParameterValues')
|
||||
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
||||
})
|
||||
|
||||
@@ -204,13 +204,16 @@ describe('CWMPTaskQueue — branch coverage', () => {
|
||||
const tq = new CWMPTaskQueue()
|
||||
const deviceId = new DeviceId('X', 'Y', 'Z', 'S')
|
||||
|
||||
tq.addCWMPTask(deviceId, 'get_parameter_values', ['a']) // PENDING
|
||||
const pendingTask = tq.addCWMPTask(deviceId, 'get_parameter_values', ['a']) // PENDING
|
||||
const { taskId: sentId } = tq.addTask(deviceId, 'set_parameter_values', {}, async () => '') // SENT
|
||||
|
||||
tq.clearPendingTasksForDevice(deviceId)
|
||||
|
||||
// clearPendingTasksForDevice clears ALL tasks (PENDING and SENT) to avoid
|
||||
// ghost entries bleeding into the next session
|
||||
const tasks = (tq as any).tasks as Map<string, any>
|
||||
expect(tasks.has(sentId)).toBe(true)
|
||||
expect(tasks.has(pendingTask.id)).toBe(false)
|
||||
expect(tasks.has(sentId)).toBe(false)
|
||||
})
|
||||
|
||||
it('registerCallback adds callback for existing task', () => {
|
||||
|
||||
Reference in new issue
Block a user