import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@adonisjs/core/services/logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })) vi.mock('#models/device', () => ({ default: { createOrUpdate: vi.fn().mockResolvedValue({}), updateOne: vi.fn().mockResolvedValue({}), }, })) vi.mock('../../../../app/models/command.js', () => ({ default: { getCollection: () => ({ find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }), }), }), updateCommand: vi.fn().mockResolvedValue({}), }, })) import { MainStateMachine, ExecutingCommandsState, OperationalState, PartialOperationalState, } 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 { DeviceParameter, DeviceProperty } from '../../../../app/domain/device/device_parameter.js' import { CWMPTaskQueue, ResultStatus } 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 { CommandStatus, CommandType } from '../../../../app/domain/commands/command.js' import { ModeType } from '../../../../app/domain/shared/mode.js' import DeviceModel from '../../../../app/models/device.js' import CommandModel from '../../../../app/models/command.js' import logger from '@adonisjs/core/services/logger' function makeDevice(dataModel: string = 'tr-181') { const d = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) d.setDataModel(dataModel as any) // Simulate Inform params (tmpParamValues) so IdleState can detect unclassified params const root = dataModel === 'tr-098' ? 'InternetGatewayDevice' : 'Device' d.setTmpParamValues([ { Name: `${root}.ManagementServer.ConnectionRequestURL`, Value: { _: 'http://cpe/' } }, ]) return d } function makeContext(device: Device) { const taskQueue = new CWMPTaskQueue() return { device, session: 'test-session', message: undefined as any, taskQueue, services: { deviceAction: new DeviceActionExecutor(taskQueue) }, } } function setupCommandMock(commands: any[]) { const collection = CommandModel.getCollection() let callCount = 0 vi.mocked(collection.find).mockImplementation(() => ({ sort: () => ({ toArray: () => { const result = callCount === 0 ? commands : [] callCount++ return Promise.resolve(result) }, }), } as any)) } describe('MainStateMachine', () => { it('starts in idle state', () => { const sm = new MainStateMachine(makeDevice(), 'sess-1', new CWMPTaskQueue()) expect(sm.getCurrentStateName()).toBe('idle') expect(sm.isCompleted()).toBe(false) }) 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. (single test path) expect(response).toContain('GetParameterNames') expect(response).toContain('Device.ManagementServer.') 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 → transitions to critical-reads (single path queue) await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }], }, }) // Only one path in queue → transitions to critical-reads expect(sm.getCurrentStateName()).toBe('critical-reads') }) it('with unknown data model and no unclassified params, skips to critical-reads', async () => { const device = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) device.setDataModel('unknown' as any) // No tmpParamValues → nothing to classify → skips to critical-reads const sm = new MainStateMachine(device, 'sess-1', new CWMPTaskQueue()) await sm.start() expect(sm.getCurrentStateName()).toBe('critical-reads') }) 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.') }) it('GPN failure sets gpnStatus unsupported and continues', async () => { const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) await sm.start() // Simulate GPN fault (9001 = method not supported) await sm.handleResponse(false, { FaultCode: 9001, FaultString: 'Method not supported' }) // Should have transitioned past discovering-parameters expect(sm.getCurrentStateName()).not.toBe('discovering-parameters') }) 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 (single path test) await sm.start() expect(sm.getCurrentStateName()).toBe('discovering-parameters') // Step 2: GPN response → transitions to critical-reads await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) expect(sm.getCurrentStateName()).toBe('critical-reads') // 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: [] }, }) } expect(sm.isCompleted()).toBe(true) }) it('error in state transitions to completed gracefully', async () => { const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) await sm.start() // Force an unexpected error by passing malformed data many times for (let i = 0; i < 20; i++) { await sm.handleResponse(false, { error: 'unexpected' }) if (sm.isCompleted()) break } // Should eventually complete (safety guard or error handling) expect(sm.isCompleted()).toBe(true) }) }) describe('ExecutingCommandsState', () => { it('returns isComplete when no commands found (includeNI=true)', async () => { const state = new ExecutingCommandsState(true) const device = makeDevice() const context = { device, session: 'sess-1', taskQueue: new CWMPTaskQueue(), services: { deviceAction: { getParameterValues: vi.fn() } as any }, } const result = await state.handle(context) // 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 () => { const state = new ExecutingCommandsState(false) const device = makeDevice() const context = { device, session: 'sess-1', taskQueue: new CWMPTaskQueue(), services: { deviceAction: {} as any }, } const result = await state.handle(context) expect(result.isComplete).toBe(true) }) }) describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN flows)', () => { beforeEach(() => { vi.clearAllMocks() }) it('sends GPN for tr-098 device when gpnStatus=unknown', async () => { const device = new Device(new DeviceId('Huawei', '202BC1', 'BM632w', 'SN001')) device.setDataModel('tr-098') device.setGpnStatus('unknown') device.setTmpParamValues([{ Name: 'InternetGatewayDevice.ManagementServer.URL', Value: { _: 'http://acs/' } }]) const taskQueue = new CWMPTaskQueue() const sm = new MainStateMachine(device, 'sess', taskQueue) const response = await sm.start() expect(response).toContain('GetParameterNames') expect(response).toContain('InternetGatewayDevice.ManagementServer.') }) it('skips GPN for unknown dataModel and goes to critical-reads', async () => { const device = new Device(new DeviceId('Unknown', '000000', 'Generic', 'SN002')) device.setDataModel('unknown') device.setGpnStatus('unknown') const taskQueue = new CWMPTaskQueue() const sm = new MainStateMachine(device, 'sess', taskQueue) const response = await sm.start() // Should go to critical-reads (GPV) since unknown data model skips GPN expect(response).toContain('GetParameterValues') }) it('DiscoveringParametersState handles GPN success and transitions to CriticalReads', async () => { const device = makeDevice('unknown') // gpnStatus=unknown → will attempt GPN device.setGpnStatus('unknown') device.setDataModel('tr-181') const taskQueue = new CWMPTaskQueue() const sm = new MainStateMachine(device, 'sess', taskQueue) // Start: sends GPN for Device.ManagementServer. (single test path) await sm.start() // GPN response → 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') }) it('handles MAX_STATE_TRANSITIONS exceeded gracefully', async () => { // Create a state machine with a device that will loop const device = makeDevice('unsupported') const taskQueue = new CWMPTaskQueue() const sm = new MainStateMachine(device, 'sess', taskQueue) // The machine won't actually loop 15 times in normal flow since it completes // Just verify the machine starts and runs without crashing const response = await sm.start() expect(response).toBeDefined() }) }) describe('OperationalState', () => { beforeEach(() => { vi.clearAllMocks() }) it('persists device with operational status', async () => { const device = makeDevice() const taskQueue = new CWMPTaskQueue() const context = { device, session: 'test-session', taskQueue, services: { deviceAction: {} as any }, } const state = new OperationalState() const result = await state.handle(context as any) expect(result.isComplete).toBe(true) expect(device.operationalStatus).toBe('operational') expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( expect.objectContaining({ operationalStatus: 'operational' }) ) }) }) describe('PartialOperationalState', () => { beforeEach(() => { vi.clearAllMocks() }) it('persists device with partial_operational status and failure reason', async () => { const device = makeDevice() const taskQueue = new CWMPTaskQueue() const context = { device, session: 'test-session', taskQueue, services: { deviceAction: {} as any }, } const state = new PartialOperationalState(device, { reason: 'test_failure', blockingState: 'some-state', }) const result = await state.handle(context as any) expect(result.isComplete).toBe(true) expect(device.operationalStatus).toBe('partial_operational') expect(device.lastFailureReason).toBe('test_failure') expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( expect.objectContaining({ operationalStatus: 'partial_operational', lastFailureReason: 'test_failure', }) ) }) })