diff --git a/tests/unit/services/cwmp/executing_commands.spec.ts b/tests/unit/services/cwmp/executing_commands.spec.ts new file mode 100644 index 0000000..7025ab3 --- /dev/null +++ b/tests/unit/services/cwmp/executing_commands.spec.ts @@ -0,0 +1,252 @@ +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 +const mockToArray = vi.fn().mockResolvedValue([]) +const mockSort = vi.fn().mockReturnValue({ toArray: mockToArray }) +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 reclassify +vi.mock('../../../../app/services/parameter_writability.js', () => ({ + reclassifyDeviceFromCatalog: vi.fn(), +})) + +import { ExecutingCommandsState, 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 { DeviceParameter, 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 { CommandStatus, CommandType } from '../../../../app/domain/commands/command.js' +import { ModeType } from '../../../../app/domain/shared/mode.js' +import { ParameterType } from '../../../../app/domain/shared/types.js' +import CommandModel from '../../../../app/models/command.js' +import logger from '@adonisjs/core/services/logger' + +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, + message: undefined as any, + services: { deviceAction: new DeviceActionExecutor(taskQueue) }, + } +} + +// Helper to inject mock commands into the ExecutingCommandsState flow +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('ExecutingCommandsState — direct testing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns isComplete when no commands and includeNI=false', async () => { + setupCommandMock([]) + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(false) + + const result = await state.handle(context) + expect(result.isComplete).toBe(true) + }) + + it('transitions to OperationalState when no commands and includeNI=true', async () => { + setupCommandMock([]) + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.nextState).toBeDefined() + expect((result.nextState as any).name).toBe('operational') + }) + + it('sends GET_PARAMETER_VALUES command', async () => { + const cmd = { _id: 'c1', action: CommandType.GET_PARAMETER_VALUES, parameters: ['Device.Info.SW'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.response).toContain('GetParameterValues') + expect(result.response).toContain('Device.Info.SW') + }) + + it('sends SET_PARAMETER_VALUES command', async () => { + const cmd = { _id: 'c2', action: CommandType.SET_PARAMETER_VALUES, parameters: [{ name: 'Device.X', value: '42', type: 'int' }], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.response).toContain('SetParameterValues') + expect(result.response).toContain('Device.X') + }) + + it('sends REBOOT command', async () => { + const cmd = { _id: 'c3', action: CommandType.REBOOT, parameters: { commandKey: 'reboot-1' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.response).toContain('Reboot') + }) + + it('sends DOWNLOAD command', async () => { + const cmd = { _id: 'c4', action: CommandType.DOWNLOAD, parameters: { commandKey: 'fw', fileType: '1 Firmware', url: 'http://x.bin' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.response).toContain('Download') + }) + + it('sends FACTORY_RESET command', async () => { + const cmd = { _id: 'c5', action: CommandType.FACTORY_RESET, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.response).toContain('FactoryReset') + }) + + it('sends SCHEDULE_INFORM command', async () => { + const cmd = { _id: 'c6', action: CommandType.SCHEDULE_INFORM, parameters: { delayInSeconds: 600 }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.response).toContain('ScheduleInform') + expect(result.response).toContain('600') + }) + + it('sends REFRESH command (GPV on known params)', async () => { + const cmd = { _id: 'c7', action: CommandType.REFRESH, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.Info.SW', '5.0', ParameterType.STRING)) + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(result.response).toContain('GetParameterValues') + expect(result.response).toContain('Device.Info.SW') + }) + + it('handles unknown command type by marking as FAILED', async () => { + const cmd = { _id: 'c8', action: 'INVALID_TYPE', parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + const result = await state.handle(context) + expect(vi.mocked(logger.error)).toHaveBeenCalled() + expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith('c8', expect.objectContaining({ status: CommandStatus.FAILED })) + }) + + it('marks command COMPLETED on success response and does SPV optimistic update', async () => { + const cmd = { _id: 'c9', action: CommandType.SET_PARAMETER_VALUES, parameters: [{ name: 'Device.X.Val', value: 'new' }], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + // First handle: loads commands, sends SPV + await state.handle(context) + + // Second handle: receives success response + context.message = { success: true, data: { Status: '0' } } + const result = await state.handle(context) + + expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith('c9', expect.objectContaining({ status: CommandStatus.COMPLETED })) + // Optimistic update + const prop = device.getProperty('Device.X.Val') + expect(prop).not.toBeNull() + expect(prop!.value).toBe('new') + // No more commands → transitions to operational + expect(result.nextState).toBeDefined() + expect((result.nextState as any).name).toBe('operational') + }) + + it('marks command FAILED on fault response', async () => { + const cmd = { _id: 'c10', action: CommandType.GET_PARAMETER_VALUES, parameters: ['Device.X'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + // First handle: loads commands, sends GPV + await state.handle(context) + + // Second handle: receives fault + context.message = { success: false, data: { FaultCode: 9001 } } + const result = await state.handle(context) + + expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith('c10', expect.objectContaining({ status: CommandStatus.FAILED })) + }) +}) + +// GPN success flow is tested in main_state_machine_extra.spec.ts and gpn_circuit_breaker.spec.ts