diff --git a/tests/unit/services/cwmp/branch_coverage.spec.ts b/tests/unit/services/cwmp/branch_coverage.spec.ts new file mode 100644 index 0000000..6afd5dd --- /dev/null +++ b/tests/unit/services/cwmp/branch_coverage.spec.ts @@ -0,0 +1,316 @@ +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 reclassify +vi.mock('../../../../app/services/parameter_writability.js', () => ({ + reclassifyDeviceFromCatalog: vi.fn(), +})) + +import { ExecutingCommandsState } from '../../../../app/services/cwmp/main_state_machine.js' +import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.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 { DeviceParameter, DeviceProperty } from '../../../../app/domain/device/device_parameter.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', + 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)) +} + +// ─── ExecutingCommandsState branch coverage ───────────────────────────────── + +describe('ExecutingCommandsState — uncovered branches', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('handles multiple commands in queue sequentially', async () => { + const cmd1 = { _id: 'c1', action: CommandType.GET_PARAMETER_VALUES, parameters: ['Device.A'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + const cmd2 = { _id: 'c2', action: CommandType.REBOOT, parameters: { commandKey: 'r' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 2 } + setupCommandMock([cmd1, cmd2]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + + // First handle: loads commands, sends first command + const r1 = await state.handle(context) + expect(r1.response).toContain('GetParameterValues') + + // Second handle: success response for first command, sends second + context.message = { success: true, data: {} } + const r2 = await state.handle(context) + expect(r2.response).toContain('Reboot') + + // Third handle: success response for second command → transitions to operational + context.message = { success: true, data: {} } + const r3 = await state.handle(context) + expect(r3.nextState).toBeDefined() + expect((r3.nextState as any).name).toBe('operational') + }) + + it('GET_PARAMETER_NAMES command with string params', async () => { + const cmd = { _id: 'c3', action: CommandType.GET_PARAMETER_NAMES, parameters: 'Device.ManagementServer.', 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('GetParameterNames') + }) + + it('GET_PARAMETER_NAMES command with object params', async () => { + const cmd = { _id: 'c4', action: CommandType.GET_PARAMETER_NAMES, parameters: { path: 'Device.Info.' }, 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('GetParameterNames') + }) + + it('SET_PARAMETER_VALUES with parameterValues property format', async () => { + const cmd = { _id: 'c5', action: CommandType.SET_PARAMETER_VALUES, parameters: { parameterValues: [{ name: 'Device.X', value: '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('SetParameterValues') + }) + + it('SET_PARAMETER_ATTRIBUTES command', async () => { + const cmd = { _id: 'c6', action: CommandType.SET_PARAMETER_ATTRIBUTES, parameters: [{ name: 'Device.X', notificationChange: true, notification: 2, accessListChange: false, accessList: [] }], 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('SetParameterAttributes') + }) + + it('UPLOAD command', async () => { + const cmd = { _id: 'c7', action: CommandType.UPLOAD, parameters: { commandKey: 'u1', fileType: '2', url: 'http://u.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('Upload') + }) + + it('CommandModel.updateCommand error is caught (line 292)', async () => { + vi.mocked(CommandModel.updateCommand).mockRejectedValueOnce(new Error('DB error')) + const cmd = { _id: 'c8', 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: sends command + await state.handle(context) + // Second handle: response processing with DB error on updateCommand + vi.mocked(CommandModel.updateCommand).mockRejectedValueOnce(new Error('DB error')) + context.message = { success: true, data: {} } + await state.handle(context) + + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + it('returns empty response for includeNI=false when all commands done (line 304)', async () => { + const cmd = { _id: 'c9', action: CommandType.REBOOT, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } + setupCommandMock([cmd]) + + const device = makeDevice() + const context = makeContext(device) + const state = new ExecutingCommandsState(false) // includeNI = false + + await state.handle(context) + context.message = { success: true, data: {} } + const result = await state.handle(context) + + // Should return empty (not transition to Operational) + expect(result.response).toBe('') + expect(result.nextState).toBeUndefined() + }) +}) + +// ─── Task Queue branch coverage ───────────────────────────────────────────── + +describe('CWMPTaskQueue — uncovered branches', () => { + it('executeTaskCallback catches callback errors (line 212-214)', async () => { + const tq = new CWMPTaskQueue() + const deviceId = new DeviceId('X', 'Y', 'Z', 'S') + + const throwingCallback = vi.fn().mockRejectedValue(new Error('callback crash')) + const { taskId } = tq.addTask(deviceId, 'get_parameter_values', [], throwingCallback) + + const response = await tq.executeTaskCallback(taskId, ResultStatus.Success, {}) + expect(response).toBe('') + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + it('clearPendingTasksForDevice removes PENDING tasks and keeps SENT (lines 227-228)', () => { + const tq = new CWMPTaskQueue() + const deviceId = new DeviceId('X', 'Y', 'Z', 'S') + + // Add tasks: one will be marked SENT (via addTask), another stays PENDING + tq.addCWMPTask(deviceId, 'get_parameter_values', ['a']) // PENDING + const { taskId: sentId } = tq.addTask(deviceId, 'set_parameter_values', {}, async () => '') // SENT + + tq.clearPendingTasksForDevice(deviceId) + + // SENT task should still exist + const tasks = (tq as any).tasks as Map + expect(tasks.has(sentId)).toBe(true) + }) + + it('registerCallback adds callback for existing task (line 124)', () => { + const tq = new CWMPTaskQueue() + const deviceId = new DeviceId('X', 'Y', 'Z', 'S') + const task = tq.addCWMPTask(deviceId, 'reboot', {}) + + const cb = vi.fn().mockResolvedValue('') + tq.registerCallback(task.id, cb) + + const callbacks = (tq as any).taskCallbacks as Map + expect(callbacks.has(task.id)).toBe(true) + }) +}) + +// ─── StateMachineManager uncovered branches ──────────────────────────────── + +describe('StateMachineManager — uncovered branches', () => { + let manager: StateMachineManager + + beforeEach(() => { + vi.clearAllMocks() + ;(StateMachineManager as any).instance = null + manager = StateMachineManager.getInstance() + }) + + it('handleCWMPResponse returns empty for non-RUNNING machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-nr', { events: ['0 BOOTSTRAP'] }) + ;(machine as any).status = MachineStatus.COMPLETED + + const result = await manager.handleCWMPResponse('sess-nr', true, {}) + expect(result).toBe('') + }) + + it('continue returns empty for non-RUNNING machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-cnr', { events: ['0 BOOTSTRAP'] }) + ;(machine as any).status = MachineStatus.FAILED + + const result = await manager.continue('sess-cnr') + expect(result).toBe('') + }) + + it('handleCWMPResponse catches state machine errors and fails machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-err', { events: ['0 BOOTSTRAP'] }) + + // Force handleResponse to throw + vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('boom')) + + const result = await manager.handleCWMPResponse('sess-err', true, {}) + expect(result).toBe('') + expect(machine.status).toBe(MachineStatus.FAILED) + }) + + it('continue catches errors and fails machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-cerr', { events: ['0 BOOTSTRAP'] }) + + // Force handleResponse to throw + vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('crash')) + + const result = await manager.continue('sess-cerr') + expect(result).toBe('') + expect(machine.status).toBe(MachineStatus.FAILED) + }) + + it('ensureSessionForRemote returns false for non-RUNNING machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-enr', { events: ['0 BOOTSTRAP'] }) + manager.bindRemoteToDevice('remote-x', device.deviceId) + ;(machine as any).status = MachineStatus.COMPLETED + + const result = manager.ensureSessionForRemote('remote-x', 'new-sess') + expect(result).toBe(false) + }) + + it('getHashIdForRemote returns undefined for invalid JSON in remoteToDevice', async () => { + ;(manager as any).remoteToDevice.set('bad-remote', 'not-valid-json{') + const result = manager.getHashIdForRemote('bad-remote') + expect(result).toBeUndefined() + }) +})