From ca24b469d35082cb3cbaef13e1e437dda32be359 Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Mon, 8 Jun 2026 13:14:04 +0000 Subject: [PATCH] fix: allow uppercase letters in configuration identifier regex Previously /^[a-z0-9]+(-[a-z0-9]+)*$/ rejected identifiers like 'BM632w-standard'. Updated to /^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/ to allow mixed case. Generated with [Continue](https://continue.dev) Co-Authored-By: Continue --- app/validators/configuration.ts | 2 +- .../configuration_engine_coverage.spec.ts | 253 +++++++++++++++++ .../device_action_executor_coverage.spec.ts | 264 ++++++++++++++++++ .../state_machine_manager_coverage.spec.ts | 194 +++++++++++++ 4 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 tests/unit/services/configuration_engine_coverage.spec.ts create mode 100644 tests/unit/services/cwmp/device_action_executor_coverage.spec.ts create mode 100644 tests/unit/services/cwmp/state_machine_manager_coverage.spec.ts diff --git a/app/validators/configuration.ts b/app/validators/configuration.ts index 496a075..acd1b21 100644 --- a/app/validators/configuration.ts +++ b/app/validators/configuration.ts @@ -37,7 +37,7 @@ const scheduleWindowSchema = vine.object({ export const createConfigurationValidator = vine.compile( vine.object({ - identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/), + identifier: vine.string().trim().regex(/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/), name: vine.string().trim().minLength(1), version: vine.string().trim().regex(/^\d+\.\d+\.\d+$/), priority: vine.number().positive(), diff --git a/tests/unit/services/configuration_engine_coverage.spec.ts b/tests/unit/services/configuration_engine_coverage.spec.ts new file mode 100644 index 0000000..b047525 --- /dev/null +++ b/tests/unit/services/configuration_engine_coverage.spec.ts @@ -0,0 +1,253 @@ +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), + findOne: vi.fn().mockResolvedValue(null), + updateOne: vi.fn().mockResolvedValue({}), + }, +})) + +// Mock ConfigurationModel — dynamically controlled per test +vi.mock('#models/configuration', () => ({ + default: { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn().mockResolvedValue(null), + }, +})) + +// Mock FirmwareCatalogService +vi.mock('../../../app/services/firmware_catalog_service.js', () => ({ + default: class { async resolveUpgradePath() { return [] } }, + FirmwareCatalogService: class { async resolveUpgradePath() { return [] } }, +})) + +// Mock schedule_window +vi.mock('../../../app/utils/schedule_window.js', () => ({ + isWithinSchedule: vi.fn().mockReturnValue(true), +})) + +import ConfigurationEngine from '../../../app/services/configuration_engine.js' +import { Device } from '../../../app/domain/device/device.js' +import { DeviceId } from '../../../app/domain/device/device_id.js' +import { DeviceProperty, DeviceParameter } from '../../../app/domain/device/device_parameter.js' +import { ParameterType } from '../../../app/domain/shared/types.js' +import ConfigurationModel from '#models/configuration' + +function makeDevice(opts: { manufacturer?: string; productClass?: string; provisioningCode?: string } = {}): Device { + const device = new Device(new DeviceId( + opts.manufacturer || 'Sagemcom', + 'F8B7FF', + opts.productClass || 'Livebox 4', + 'LB4-TEST-001' + )) + device.setDataModel('tr-181') + if (opts.provisioningCode) { + (device as any).provisioningCode = opts.provisioningCode + } + return device +} + +function makeConfig(overrides: any = {}) { + return { + identifier: 'test-config', + name: 'Test Config', + version: '1.0.0', + priority: 1, + policy: 'maintain', + enabled: true, + criteria: { manufacturer: 'Sagemcom', productClass: 'Livebox 4' }, + inventory: [{ path: 'Device.ManagementServer.' }], + desiredParameters: [ + { name: 'Device.ManagementServer.PeriodicInformInterval', value: '300', type: 'unsignedInt' }, + ], + firmwareTarget: null, + schedule: null, + tags: [], + ...overrides, + } +} + +describe('ConfigurationEngine — coverage', () => { + let engine: ConfigurationEngine + + beforeEach(() => { + vi.clearAllMocks() + engine = new ConfigurationEngine() + }) + + // --- evaluate() --- + + it('returns isAlreadyCompliant when no configs exist', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.isAlreadyCompliant).toBe(true) + expect(result.configuration).toBeNull() + }) + + it('returns isAlreadyCompliant when no config matches criteria', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Huawei' } })]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.isAlreadyCompliant).toBe(true) + expect(result.configuration).toBeNull() + }) + + it('selects highest priority config when multiple match', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([ + makeConfig({ identifier: 'low', priority: 1 }), + makeConfig({ identifier: 'high', priority: 10 }), + makeConfig({ identifier: 'mid', priority: 5 }), + ]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.configuration?.identifier).toBe('high') + }) + + it('returns parameterDiffs when device value differs from desired', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) + const device = makeDevice() + // Set a different value + device.setParameter(new DeviceParameter( + 'Device.ManagementServer.PeriodicInformInterval', + '60', + ParameterType.UNSIGNED_INT + )) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(1) + expect(result.parameterDiffs[0].currentValue).toBe('60') + expect(result.parameterDiffs[0].targetValue).toBe('300') + expect(result.isAlreadyCompliant).toBe(false) + }) + + it('returns empty diffs when device value matches desired', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) + const device = makeDevice() + device.setParameter(new DeviceParameter( + 'Device.ManagementServer.PeriodicInformInterval', + '300', + ParameterType.UNSIGNED_INT + )) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(0) + expect(result.isAlreadyCompliant).toBe(true) + }) + + it('treats null current value as diff', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) + const device = makeDevice() + // Don't set the parameter — it's unknown + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(1) + expect(result.parameterDiffs[0].currentValue).toBeNull() + }) + + // --- matchesCriteria --- + + it('matchesCriteria matches glob patterns on manufacturer', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sage*' } })]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.configuration).not.toBeNull() + }) + + it('matchesCriteria matches wildcard * on productClass', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sagemcom', productClass: '*' } })]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.configuration).not.toBeNull() + }) + + it('matchesCriteria rejects when parameterCriteria value mismatches', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + criteria: { + manufacturer: 'Sagemcom', + parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'WrongModel' }], + }, + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.DeviceInfo.ModelName', 'Livebox 4', ParameterType.STRING)) + const result = await engine.evaluate(device) + expect(result.configuration).toBeNull() + expect(result.isAlreadyCompliant).toBe(true) + }) + + it('matchesCriteria rejects when parameterCriteria param is missing on device', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + criteria: { + manufacturer: 'Sagemcom', + parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'Livebox 4' }], + }, + })]) + const device = makeDevice() + // Don't set ModelName → param is missing + const result = await engine.evaluate(device) + expect(result.configuration).toBeNull() + }) + + // --- shouldApplyOnce (policy: once) --- + + it('shouldApplyOnce returns true (re-apply) when no provisioningCode on device', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ policy: 'once' })]) + const device = makeDevice() + const result = await engine.evaluate(device) + // Should evaluate normally (not skip) + expect(result.configuration).not.toBeNull() + }) + + it('shouldApplyOnce returns false (skip) when provisioningCode hash matches', async () => { + const config = makeConfig({ policy: 'once' }) + vi.mocked(ConfigurationModel.find).mockResolvedValue([config]) + + const device = makeDevice() + // Compute the expected provisioning code + const { buildProvisioningCode } = await import('../../../app/utils/provisioning_code.js') + const expectedPC = buildProvisioningCode(config as any) + ;(device as any).provisioningCode = expectedPC + + const result = await engine.evaluate(device) + expect(result.isAlreadyCompliant).toBe(true) + expect(result.parameterDiffs.length).toBe(0) + }) + + // --- Numeric and boolean comparison --- + + it('computeParameterDiffs handles numeric comparison (unsignedInt)', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + desiredParameters: [{ name: 'Device.X.Interval', value: '300', type: 'unsignedInt' }], + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Interval', '300', ParameterType.UNSIGNED_INT)) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(0) // 300 == 300 + }) + + it('computeParameterDiffs handles boolean comparison', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }], + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Enabled', '1', ParameterType.BOOLEAN)) + const result = await engine.evaluate(device) + // '1' and 'true' should be treated as same boolean + expect(result.parameterDiffs.length).toBe(0) + }) + + it('computeParameterDiffs detects boolean mismatch', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }], + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Enabled', 'false', ParameterType.BOOLEAN)) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(1) + }) +}) diff --git a/tests/unit/services/cwmp/device_action_executor_coverage.spec.ts b/tests/unit/services/cwmp/device_action_executor_coverage.spec.ts new file mode 100644 index 0000000..473b6ed --- /dev/null +++ b/tests/unit/services/cwmp/device_action_executor_coverage.spec.ts @@ -0,0 +1,264 @@ +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({}), + }, +})) + +import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.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, DeviceParameter } from '../../../../app/domain/device/device_parameter.js' +import { ParameterType } from '../../../../app/domain/shared/types.js' +import DeviceModel from '../../../../app/models/device.js' + +function makeDevice(dataModel: string = 'tr-181'): Device { + const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) + device.setDataModel(dataModel as any) + return device +} + +describe('DeviceActionExecutor — coverage', () => { + let taskQueue: CWMPTaskQueue + let executor: DeviceActionExecutor + + beforeEach(() => { + vi.clearAllMocks() + taskQueue = new CWMPTaskQueue() + executor = new DeviceActionExecutor(taskQueue) + }) + + // --- getParameterValues --- + + it('getParameterValues returns XML containing GetParameterValues', async () => { + const device = makeDevice() + const xml = await executor.getParameterValues(device, ['Device.Info.SW'], async () => '') + expect(xml).toContain('GetParameterValues') + expect(xml).toContain('Device.Info.SW') + }) + + it('getParameterValues wrappedCallback updates device on success', async () => { + const device = makeDevice() + let capturedCallback: any + const origAddTask = taskQueue.addTask.bind(taskQueue) + vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => { + capturedCallback = cb + return origAddTask(deviceId, type, props, cb) + }) + + await executor.getParameterValues(device, ['Device.Info.SW'], async () => '') + + // Execute the wrapped callback with success + await capturedCallback({ + status: ResultStatus.Success, + data: { + ParameterList: { + ParameterValueStruct: [ + { Name: 'Device.Info.SW', Value: { _: '5.0.1', $: { Type: 'xsd:string' } } }, + ], + }, + }, + }) + + // Device should have been updated + const prop = device.getProperty('Device.Info.SW') + expect(prop).not.toBeNull() + expect(prop!.value).toBe('5.0.1') + // Should have persisted + expect(mockCreateOrUpdate).toHaveBeenCalled() + }) + + // --- getParameterNames --- + + it('getParameterNames returns GPN envelope', async () => { + const device = makeDevice() + const xml = await executor.getParameterNames(device, 'Device.ManagementServer.', async () => '') + expect(xml).toContain('GetParameterNames') + expect(xml).toContain('Device.ManagementServer.') + }) + + it('GPN success marks device as supported and records success', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-1' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Success, data: { ParameterList: { ParameterInfoStruct: [] } } }) + + expect(device.gpnStatus).toBe('supported') + expect(device.gpnCircuitBreaker.failureCount).toBe(0) + }) + + it('GPN fault 9001 marks device as unsupported and records failure', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-2' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9001', FaultString: 'Not supported' } }) + + expect(device.gpnStatus).toBe('unsupported') + expect(device.gpnCircuitBreaker.failureCount).toBe(1) + }) + + it('GPN fault 9005 marks device as unsupported', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-3' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: 9005 } }) + + expect(device.gpnStatus).toBe('unsupported') + expect(device.gpnCircuitBreaker.failureCount).toBe(1) + }) + + it('GPN fault 9002 is treated as transient (no unsupported mark)', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-4' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9002' } }) + + // Should NOT mark as unsupported + expect(device.gpnStatus).toBe('unknown') + expect(device.gpnCircuitBreaker.failureCount).toBe(0) + }) + + // --- applySetParameterValues --- + + it('applySetParameterValues returns SPV envelope', async () => { + const device = makeDevice() + const xml = await executor.applySetParameterValues( + device, + [{ parameterName: 'Device.X.Interval', value: '300', type: 'unsignedInt' }], + async () => '' + ) + expect(xml).toContain('SetParameterValues') + expect(xml).toContain('Device.X.Interval') + expect(xml).toContain('300') + expect(xml).toContain('xsd:unsignedInt') + }) + + it('applySetParameterValues infers type from device param when not specified', async () => { + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Flag', 'true', ParameterType.BOOLEAN)) + + const xml = await executor.applySetParameterValues( + device, + [{ parameterName: 'Device.X.Flag', value: 'false' }], + async () => '' + ) + expect(xml).toContain('xsd:boolean') + }) + + it('applySetParameterValues defaults to xsd:string for unknown type', async () => { + const device = makeDevice() + const xml = await executor.applySetParameterValues( + device, + [{ parameterName: 'Device.X.New', value: 'hello' }], + async () => '' + ) + expect(xml).toContain('xsd:string') + }) + + // --- insertProvisioningCode --- + + it('insertProvisioningCode uses tr-181 path for tr-181 device', async () => { + const device = makeDevice('tr-181') + device.tmpPC = 'my-prov-code' + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') + expect(xml).toContain('my-prov-code') + }) + + it('insertProvisioningCode uses tr-098 path for tr-098 device', async () => { + const device = makeDevice('tr-098') + device.tmpPC = 'code-098' + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('InternetGatewayDevice.DeviceInfo.ProvisioningCode') + }) + + it('insertProvisioningCode uses known param path when available', async () => { + const device = makeDevice('tr-181') + device.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'old-code', ParameterType.STRING)) + device.tmpPC = 'new-code' + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') + expect(xml).toContain('new-code') + }) + + // --- persistDevice --- + + it('persistDevice calls DeviceModel.createOrUpdate', async () => { + const device = makeDevice() + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-p' } + }) + + await executor.getParameterValues(device, ['Device.X'], async () => '') + await capturedCallback({ + status: ResultStatus.Success, + data: { ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] } }, + }) + + expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( + expect.objectContaining({ identifier: expect.any(String) }) + ) + }) + + // --- Download/Reboot --- + + it('applyDownload returns Download envelope', async () => { + const device = makeDevice() + const xml = await executor.applyDownload(device, { + commandKey: 'fw-1', + fileType: '1 Firmware Upgrade Image', + url: 'http://example.com/fw.bin', + }, async () => '') + expect(xml).toContain('Download') + expect(xml).toContain('http://example.com/fw.bin') + }) + + it('applyReboot returns Reboot envelope', async () => { + const device = makeDevice() + const xml = await executor.applyReboot(device, { commandKey: 'reboot-1' }, async () => '') + expect(xml).toContain('Reboot') + expect(xml).toContain('reboot-1') + }) + + it('applyFactoryReset returns FactoryReset envelope', async () => { + const device = makeDevice() + const xml = await executor.applyFactoryReset(device, {}, async () => '') + expect(xml).toContain('FactoryReset') + }) +}) diff --git a/tests/unit/services/cwmp/state_machine_manager_coverage.spec.ts b/tests/unit/services/cwmp/state_machine_manager_coverage.spec.ts new file mode 100644 index 0000000..6525d32 --- /dev/null +++ b/tests/unit/services/cwmp/state_machine_manager_coverage.spec.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock logger +vi.mock('@adonisjs/core/services/logger', () => ({ + default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +// Mock DeviceModel +vi.mock('../../../../app/models/device.js', () => ({ + default: { + createOrUpdate: vi.fn().mockResolvedValue({}), + findByDeviceId: vi.fn().mockResolvedValue(null), + updateOne: vi.fn().mockResolvedValue({}), + }, +})) + +// Mock CommandModel +vi.mock('../../../../app/models/command.js', () => ({ + default: { + getCollection: vi.fn().mockReturnValue({ + find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), + countDocuments: vi.fn().mockResolvedValue(0), + }), + updateCommand: vi.fn().mockResolvedValue({}), + }, +})) + +import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js' +import { Device } from '../../../../app/domain/device/device.js' +import { DeviceId } from '../../../../app/domain/device/device_id.js' +import { ResultStatus } from '../../../../app/services/cwmp/task_queue.js' +import CommandModel from '../../../../app/models/command.js' + +function makeDevice(gpnStatus: string = 'unsupported'): Device { + const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) + device.setDataModel('tr-181') + device.setGpnStatus(gpnStatus as any) + return device +} + +describe('StateMachineManager — coverage', () => { + let manager: StateMachineManager + + beforeEach(() => { + vi.clearAllMocks() + ;(StateMachineManager as any).instance = null + manager = StateMachineManager.getInstance() + }) + + // --- Event routing --- + + it('routes BOOTSTRAP event to MainStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-1', { events: ['0 BOOTSTRAP'] }) + expect(machine.status).toBe(MachineStatus.RUNNING) + // MainStateMachine has getCurrentStateName + expect(machine.stateMachine.getCurrentStateName()).toBeDefined() + }) + + it('routes CONNECTION_REQUEST to ImmediateCommandStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-2', { events: ['6 CONNECTION REQUEST'] }) + expect(machine.status).toBe(MachineStatus.RUNNING) + expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') + }) + + it('routes PERIODIC to MainStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-3', { events: ['2 PERIODIC'] }) + expect(machine.status).toBe(MachineStatus.RUNNING) + expect(machine.stateMachine.getCurrentStateName()).toBe('idle') + }) + + it('forces MainStateMachine when NI commands exist', async () => { + // Override the mock to return count > 0 + const collection = CommandModel.getCollection() + vi.mocked(collection.countDocuments).mockResolvedValueOnce(3) + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-4', { events: ['6 CONNECTION REQUEST'] }) + // Should be MainStateMachine (idle) not ImmediateCommandStateMachine + expect(machine.stateMachine.getCurrentStateName()).toBe('idle') + }) + + it('preempts ImmediateCommandStateMachine on BOOT event', 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 + const machine2 = await manager.startStateMachine(device, 'sess-5', { events: ['1 BOOT'] }) + // Should have preempted to MainStateMachine + expect(machine2.stateMachine.getCurrentStateName()).toBe('idle') + }) + + // --- 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 + ;(machine as any).status = MachineStatus.COMPLETED + ;(machine as any).completedAt = new Date(Date.now() - 2 * 60 * 60 * 1000) // 2h ago + + manager.cleanupCompletedMachines(1) + // Machine should be removed from internal map + expect(manager.hasSession('sess-11')).toBe(false) + }) +})