Files
adam-acs/tests/unit/services/cwmp/main_state_machine.spec.ts
T

315 lines
12 KiB
TypeScript

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',
})
)
})
})