test: raise business-logic unit test coverage across core services
Adds targeted unit tests to cover previously untested branches/functions in the business-critical (non-protocol) services: - device_service.ts: 35.8% -> 98.76% (CRUD, executeCommand, refreshDevice, calculatePagination, IPv4/IPv6 Connection Request fallback chain) - domain/device/device.ts: 76.7% -> 96.59% (GPN circuit breaker state machine, session lifecycle, canExecuteCommands, detectDataModelFromParams) - services/references/reference_engine.ts: 78.66% -> 100% (provider loading edge cases, rejection handling, singleton lifecycle) - services/configuration_engine.ts: 70.96% -> 98.7% (matchesCriteria branches, shouldApplyOnce policy variants, dryRun, complianceReport) - services/cwmp/state_machine_manager.ts: 70.9% -> 87.27% (getHashIdForRemoteIp, ensureSessionForRemote IP fallback, task callback error handling) - services/cwmp/main_state_machine.ts: 57.79% -> 94.29% (ExecutingCommandsState full command-type dispatch, MAX_STATE_TRANSITIONS guard, error-> PartialOperationalState transitions) Also fixes: - reference_engine_full.spec.ts previously depended on the real config/references.json (production config, wassup provider legitimately disabled there) instead of a test fixture — now mocks config_loader for deterministic, isolated assertions - main_state_machine.spec.ts CommandModel.getCollection mock returned a new object per call instead of a shared instance, breaking setupCommandMock() for the new command-dispatch tests Overall project coverage (excluding protocol/): 72.76% -> 82.34% statements. Full suite: 1005/1005 tests passing.
This commit is contained in:
1 parent
576b068cf4
commit
76e75132d5
7 files changed
+2326
-136
No files matched your search
@@ -36,9 +36,7 @@ vi.mock('../../app/models/configuration.js', () => ({
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
}),
|
||||
findAllEnabled: vi.fn(() => {
|
||||
return Promise.resolve(
|
||||
[...store.configurations.values()].filter((c) => c.enabled)
|
||||
)
|
||||
return Promise.resolve([...store.configurations.values()].filter((c) => c.enabled))
|
||||
}),
|
||||
createIfNotExists: vi.fn((data: Partial<Configuration>) => {
|
||||
if (store.configurations.has(data.identifier!)) {
|
||||
@@ -79,13 +77,6 @@ vi.mock('../../app/models/configuration_version.js', () => ({
|
||||
if (versions.length === 0) return Promise.resolve(0)
|
||||
return Promise.resolve(Math.max(...versions.map((v) => v.versionNumber)))
|
||||
}),
|
||||
findByConfigurationIdentifier: vi.fn((configurationIdentifier: string) => {
|
||||
return Promise.resolve(
|
||||
store.versions
|
||||
.filter((v) => v.configurationIdentifier === configurationIdentifier)
|
||||
.sort((a, b) => b.versionNumber - a.versionNumber)
|
||||
)
|
||||
}),
|
||||
deleteByConfigurationIdentifier: vi.fn((configurationIdentifier: string) => {
|
||||
const before = store.versions.length
|
||||
store.versions = store.versions.filter(
|
||||
@@ -93,23 +84,21 @@ vi.mock('../../app/models/configuration_version.js', () => ({
|
||||
)
|
||||
return Promise.resolve(before - store.versions.length)
|
||||
}),
|
||||
paginateVersions: vi.fn(
|
||||
(configurationIdentifier: string, page: number, perPage: number) => {
|
||||
const all = store.versions
|
||||
.filter((v) => v.configurationIdentifier === configurationIdentifier)
|
||||
.sort((a, b) => b.versionNumber - a.versionNumber)
|
||||
const total = all.length
|
||||
const start = (page - 1) * perPage
|
||||
const data = all.slice(start, start + perPage)
|
||||
return Promise.resolve({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
})
|
||||
}
|
||||
),
|
||||
paginateVersions: vi.fn((configurationIdentifier: string, page: number, perPage: number) => {
|
||||
const all = store.versions
|
||||
.filter((v) => v.configurationIdentifier === configurationIdentifier)
|
||||
.sort((a, b) => b.versionNumber - a.versionNumber)
|
||||
const total = all.length
|
||||
const start = (page - 1) * perPage
|
||||
const data = all.slice(start, start + perPage)
|
||||
return Promise.resolve({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
})
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
Device,
|
||||
OnboardingStatus,
|
||||
detectDataModelFromParams,
|
||||
} from '../../../app/domain/device/device.js'
|
||||
import { DeviceId } from '../../../app/domain/device/device_id.js'
|
||||
import { DeviceParameter } from '../../../app/domain/device/device_parameter.js'
|
||||
|
||||
function makeDevice(): Device {
|
||||
return new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 6', 'SN001'))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Device.isGpnAllowed — circuit breaker states', () => {
|
||||
it('allows GPN when circuit is closed (default)', () => {
|
||||
const d = makeDevice()
|
||||
expect(d.gpnCircuitBreaker.state).toBe('closed')
|
||||
expect(d.isGpnAllowed()).toBe(true)
|
||||
})
|
||||
|
||||
it('allows GPN when circuit is half-open', () => {
|
||||
const d = makeDevice()
|
||||
d.setGpnCircuitBreaker({
|
||||
failureCount: 2,
|
||||
lastFailureDate: new Date(),
|
||||
state: 'half-open',
|
||||
cooldownUntil: null,
|
||||
})
|
||||
expect(d.isGpnAllowed()).toBe(true)
|
||||
})
|
||||
|
||||
it('denies GPN when circuit is open and cooldown has not expired', () => {
|
||||
const d = makeDevice()
|
||||
d.setGpnCircuitBreaker({
|
||||
failureCount: 2,
|
||||
lastFailureDate: new Date(),
|
||||
state: 'open',
|
||||
cooldownUntil: new Date(Date.now() + 60_000),
|
||||
})
|
||||
expect(d.isGpnAllowed()).toBe(false)
|
||||
})
|
||||
|
||||
it('denies GPN when circuit is open and cooldownUntil is null (never transitions)', () => {
|
||||
const d = makeDevice()
|
||||
d.setGpnCircuitBreaker({
|
||||
failureCount: 2,
|
||||
lastFailureDate: new Date(),
|
||||
state: 'open',
|
||||
cooldownUntil: null,
|
||||
})
|
||||
expect(d.isGpnAllowed()).toBe(false)
|
||||
})
|
||||
|
||||
it('transitions open → half-open and allows GPN once cooldown has expired', () => {
|
||||
const d = makeDevice()
|
||||
d.setGpnCircuitBreaker({
|
||||
failureCount: 2,
|
||||
lastFailureDate: new Date(),
|
||||
state: 'open',
|
||||
cooldownUntil: new Date(Date.now() - 1000), // already expired
|
||||
})
|
||||
|
||||
const allowed = d.isGpnAllowed()
|
||||
|
||||
expect(allowed).toBe(true)
|
||||
expect(d.gpnCircuitBreaker.state).toBe('half-open')
|
||||
expect(d.gpnCircuitBreaker.cooldownUntil).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device.recordGpnFailure', () => {
|
||||
it('increments failureCount and stays closed below threshold', () => {
|
||||
const d = makeDevice()
|
||||
d.recordGpnFailure()
|
||||
expect(d.gpnCircuitBreaker.failureCount).toBe(1)
|
||||
expect(d.gpnCircuitBreaker.state).toBe('closed')
|
||||
expect(d.gpnCircuitBreaker.lastFailureDate).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens the circuit with 24h cooldown after 2 failures', () => {
|
||||
const d = makeDevice()
|
||||
d.recordGpnFailure()
|
||||
d.recordGpnFailure()
|
||||
|
||||
expect(d.gpnCircuitBreaker.failureCount).toBe(2)
|
||||
expect(d.gpnCircuitBreaker.state).toBe('open')
|
||||
expect(d.gpnCircuitBreaker.cooldownUntil).not.toBeNull()
|
||||
|
||||
const expectedCooldown = 24 * 60 * 60 * 1000
|
||||
const actualDelta = d.gpnCircuitBreaker.cooldownUntil!.getTime() - Date.now()
|
||||
expect(actualDelta).toBeGreaterThan(expectedCooldown - 5000)
|
||||
expect(actualDelta).toBeLessThanOrEqual(expectedCooldown)
|
||||
})
|
||||
|
||||
it('re-opens with 72h cooldown when a half-open probe fails', () => {
|
||||
const d = makeDevice()
|
||||
d.setGpnCircuitBreaker({
|
||||
failureCount: 2,
|
||||
lastFailureDate: new Date(),
|
||||
state: 'half-open',
|
||||
cooldownUntil: null,
|
||||
})
|
||||
|
||||
d.recordGpnFailure()
|
||||
|
||||
expect(d.gpnCircuitBreaker.state).toBe('open')
|
||||
expect(d.gpnCircuitBreaker.failureCount).toBe(3)
|
||||
const expectedCooldown = 72 * 60 * 60 * 1000
|
||||
const actualDelta = d.gpnCircuitBreaker.cooldownUntil!.getTime() - Date.now()
|
||||
expect(actualDelta).toBeGreaterThan(expectedCooldown - 5000)
|
||||
expect(actualDelta).toBeLessThanOrEqual(expectedCooldown)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device.recordGpnSuccess', () => {
|
||||
it('resets circuit breaker to closed with zero failures', () => {
|
||||
const d = makeDevice()
|
||||
d.recordGpnFailure()
|
||||
d.recordGpnFailure()
|
||||
expect(d.gpnCircuitBreaker.state).toBe('open')
|
||||
|
||||
d.recordGpnSuccess()
|
||||
|
||||
expect(d.gpnCircuitBreaker).toEqual({
|
||||
failureCount: 0,
|
||||
lastFailureDate: null,
|
||||
state: 'closed',
|
||||
cooldownUntil: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device.recordCrashPattern', () => {
|
||||
it('immediately opens circuit with 7-day cooldown', () => {
|
||||
const d = makeDevice()
|
||||
d.recordCrashPattern()
|
||||
|
||||
expect(d.gpnCircuitBreaker.state).toBe('open')
|
||||
expect(d.gpnCircuitBreaker.failureCount).toBe(1)
|
||||
const expectedCooldown = 7 * 24 * 60 * 60 * 1000
|
||||
const actualDelta = d.gpnCircuitBreaker.cooldownUntil!.getTime() - Date.now()
|
||||
expect(actualDelta).toBeGreaterThan(expectedCooldown - 5000)
|
||||
expect(actualDelta).toBeLessThanOrEqual(expectedCooldown)
|
||||
})
|
||||
|
||||
it('accumulates failureCount across repeated crash patterns', () => {
|
||||
const d = makeDevice()
|
||||
d.recordCrashPattern()
|
||||
d.recordCrashPattern()
|
||||
|
||||
expect(d.gpnCircuitBreaker.failureCount).toBe(2)
|
||||
expect(d.gpnCircuitBreaker.state).toBe('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device session lifecycle', () => {
|
||||
it('attachSession sets session and updates lastSeen', () => {
|
||||
const d = makeDevice()
|
||||
const before = d.lastSeen
|
||||
const session = { id: 'sess-1' }
|
||||
|
||||
d.attachSession(session)
|
||||
|
||||
expect(d.session).toBe(session)
|
||||
expect(d.isOnline()).toBe(true)
|
||||
expect(d.lastSeen.getTime()).toBeGreaterThanOrEqual(before.getTime())
|
||||
})
|
||||
|
||||
it('detachSession clears the session', () => {
|
||||
const d = makeDevice()
|
||||
d.attachSession({ id: 'sess-1' })
|
||||
d.detachSession()
|
||||
|
||||
expect(d.session).toBeNull()
|
||||
expect(d.isOnline()).toBe(false)
|
||||
})
|
||||
|
||||
it('detachSession is a no-op when there is no active session', () => {
|
||||
const d = makeDevice()
|
||||
expect(() => d.detachSession()).not.toThrow()
|
||||
expect(d.isOnline()).toBe(false)
|
||||
})
|
||||
|
||||
it('isOnline returns false by default (no session attached)', () => {
|
||||
expect(makeDevice().isOnline()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device.updateOnboardingStatus', () => {
|
||||
it('updates onboardingStatus and lastSeen', () => {
|
||||
const d = makeDevice()
|
||||
const before = d.lastSeen
|
||||
|
||||
d.updateOnboardingStatus(OnboardingStatus.COMPLETED)
|
||||
|
||||
expect(d.onboardingStatus).toBe(OnboardingStatus.COMPLETED)
|
||||
expect(d.lastSeen.getTime()).toBeGreaterThanOrEqual(before.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device.canExecuteCommands', () => {
|
||||
it('returns false when offline even if onboarding is COMPLETED', () => {
|
||||
const d = makeDevice()
|
||||
d.updateOnboardingStatus(OnboardingStatus.COMPLETED)
|
||||
expect(d.canExecuteCommands()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when online but onboarding is not COMPLETED', () => {
|
||||
const d = makeDevice()
|
||||
d.attachSession({ id: 'sess-1' })
|
||||
expect(d.canExecuteCommands()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when online AND onboarding is COMPLETED', () => {
|
||||
const d = makeDevice()
|
||||
d.attachSession({ id: 'sess-1' })
|
||||
d.updateOnboardingStatus(OnboardingStatus.COMPLETED)
|
||||
expect(d.canExecuteCommands()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device parameter/property update no-op paths', () => {
|
||||
it('updateParameter does nothing when parameter does not exist', () => {
|
||||
const d = makeDevice()
|
||||
expect(() => d.updateParameter('Device.Unknown', 'value')).not.toThrow()
|
||||
expect(d.getParameter('Device.Unknown')).toBeNull()
|
||||
})
|
||||
|
||||
it('updateProperty does nothing when property does not exist', () => {
|
||||
const d = makeDevice()
|
||||
expect(() => d.updateProperty('Device.Unknown', 'value')).not.toThrow()
|
||||
expect(d.getProperty('Device.Unknown')).toBeNull()
|
||||
})
|
||||
|
||||
it('removeProperty deletes an existing property', () => {
|
||||
const d = makeDevice()
|
||||
d.setProperty(new DeviceParameter('Device.X', 'v') as any)
|
||||
d.removeProperty('Device.X')
|
||||
expect(d.getProperty('Device.X')).toBeNull()
|
||||
})
|
||||
|
||||
it('removeProperty is a no-op for a non-existent property', () => {
|
||||
const d = makeDevice()
|
||||
expect(() => d.removeProperty('Device.DoesNotExist')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device tmp PC (provisioning code) storage', () => {
|
||||
it('setTmpPC / getTmpPC round-trip', () => {
|
||||
const d = makeDevice()
|
||||
d.setTmpPC('some-code')
|
||||
expect(d.getTmpPC()).toBe('some-code')
|
||||
})
|
||||
|
||||
it('getTmpPC returns null by default', () => {
|
||||
expect(makeDevice().getTmpPC()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectDataModelFromParams', () => {
|
||||
it('detects tr-181 from a "Device." prefixed name', () => {
|
||||
const model = detectDataModelFromParams([{ Name: 'Device.DeviceInfo.Manufacturer' } as any])
|
||||
expect(model).toBe('tr-181')
|
||||
})
|
||||
|
||||
it('detects tr-098 from an "InternetGatewayDevice." prefixed name', () => {
|
||||
const model = detectDataModelFromParams([
|
||||
{ Name: 'InternetGatewayDevice.DeviceInfo.Manufacturer' } as any,
|
||||
])
|
||||
expect(model).toBe('tr-098')
|
||||
})
|
||||
|
||||
it('returns "unknown" for an empty list', () => {
|
||||
expect(detectDataModelFromParams([])).toBe('unknown')
|
||||
})
|
||||
|
||||
it('returns "unknown" for an unrecognized name prefix', () => {
|
||||
const model = detectDataModelFromParams([{ Name: 'Something.Else' } as any])
|
||||
expect(model).toBe('unknown')
|
||||
})
|
||||
|
||||
it('handles Name as an XMLParser text-node object ({ _: string })', () => {
|
||||
const model = detectDataModelFromParams([
|
||||
{ Name: { _: 'Device.DeviceInfo.Manufacturer' } } as any,
|
||||
])
|
||||
expect(model).toBe('tr-181')
|
||||
})
|
||||
|
||||
it('skips entries with a null/empty Name and continues scanning', () => {
|
||||
const model = detectDataModelFromParams([
|
||||
{ Name: '' } as any,
|
||||
{ Name: null } as any,
|
||||
{ Name: 'Device.DeviceInfo.Manufacturer' } as any,
|
||||
])
|
||||
expect(model).toBe('tr-181')
|
||||
})
|
||||
|
||||
it('handles null/undefined paramValues gracefully', () => {
|
||||
expect(detectDataModelFromParams(null as any)).toBe('unknown')
|
||||
expect(detectDataModelFromParams(undefined as any)).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ vi.mock('../../../app/models/device.js', () => ({
|
||||
findByDeviceId: vi.fn().mockResolvedValue(null),
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
updateOne: vi.fn().mockResolvedValue({}),
|
||||
getCollection: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -25,8 +26,16 @@ vi.mock('#models/configuration', () => ({
|
||||
|
||||
// Mock FirmwareCatalogService
|
||||
vi.mock('../../../app/services/firmware_catalog_service.js', () => ({
|
||||
default: class { async resolveUpgradePath() { return [] } },
|
||||
FirmwareCatalogService: class { async resolveUpgradePath() { return [] } },
|
||||
default: class {
|
||||
async resolveUpgradePath() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
FirmwareCatalogService: class {
|
||||
async resolveUpgradePath() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock schedule_window
|
||||
@@ -40,17 +49,22 @@ 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'
|
||||
import DeviceModel from '../../../app/models/device.js'
|
||||
|
||||
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'
|
||||
))
|
||||
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
|
||||
;(device as any).provisioningCode = opts.provisioningCode
|
||||
}
|
||||
return device
|
||||
}
|
||||
@@ -94,7 +108,9 @@ describe('ConfigurationEngine — coverage', () => {
|
||||
})
|
||||
|
||||
it('returns isAlreadyCompliant when no config matches criteria', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Huawei' } })])
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({ criteria: { manufacturer: 'Huawei' } }),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.isAlreadyCompliant).toBe(true)
|
||||
@@ -116,11 +132,13 @@ describe('ConfigurationEngine — coverage', () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()])
|
||||
const device = makeDevice()
|
||||
// Set a different value
|
||||
device.setParameter(new DeviceParameter(
|
||||
'Device.ManagementServer.PeriodicInformInterval',
|
||||
'60',
|
||||
ParameterType.UNSIGNED_INT
|
||||
))
|
||||
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')
|
||||
@@ -131,11 +149,13 @@ describe('ConfigurationEngine — coverage', () => {
|
||||
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
|
||||
))
|
||||
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)
|
||||
@@ -153,40 +173,50 @@ describe('ConfigurationEngine — coverage', () => {
|
||||
// --- matchesCriteria ---
|
||||
|
||||
it('matchesCriteria matches glob patterns on manufacturer', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sage*' } })])
|
||||
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: '*' } })])
|
||||
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' }],
|
||||
},
|
||||
})])
|
||||
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))
|
||||
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' }],
|
||||
},
|
||||
})])
|
||||
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)
|
||||
@@ -221,9 +251,11 @@ describe('ConfigurationEngine — coverage', () => {
|
||||
// --- 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' }],
|
||||
})])
|
||||
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)
|
||||
@@ -231,9 +263,11 @@ describe('ConfigurationEngine — coverage', () => {
|
||||
})
|
||||
|
||||
it('computeParameterDiffs handles boolean comparison', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({
|
||||
desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }],
|
||||
})])
|
||||
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)
|
||||
@@ -242,12 +276,453 @@ describe('ConfigurationEngine — coverage', () => {
|
||||
})
|
||||
|
||||
it('computeParameterDiffs detects boolean mismatch', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({
|
||||
desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }],
|
||||
})])
|
||||
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)
|
||||
})
|
||||
|
||||
// --- matchesCriteria: oui / productClass / serialNumber branches ---
|
||||
|
||||
it('matchesCriteria rejects when oui does not match', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
criteria: { oui: 'DEADBEEF' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria matches when oui matches exactly', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
criteria: { oui: 'F8B7FF' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).not.toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria rejects when productClass does not match', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
criteria: { productClass: 'SomethingElse' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria rejects when serialNumber does not match', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
criteria: { serialNumber: 'DIFFERENT-SN' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria matches when serialNumber matches exactly', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
criteria: { serialNumber: 'LB4-TEST-001' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).not.toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria returns false for a device without deviceId', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()])
|
||||
const result = await engine.evaluate({} as any)
|
||||
expect(result.configuration).toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria accepts a parameterCriteria match found on plain DeviceRecord (no getParameters)', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
criteria: {
|
||||
manufacturer: 'Sagemcom',
|
||||
parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'Livebox 4' }],
|
||||
},
|
||||
}),
|
||||
])
|
||||
// A plain DeviceRecord-like object (no getParameters/getProperties methods)
|
||||
const plainDevice: any = {
|
||||
deviceId: {
|
||||
manufacturer: 'Sagemcom',
|
||||
oui: 'F8B7FF',
|
||||
productClass: 'Livebox 4',
|
||||
serialNumber: 'SN1',
|
||||
},
|
||||
parameters: [{ name: 'Device.DeviceInfo.ModelName', value: 'Livebox 4' }],
|
||||
properties: [],
|
||||
}
|
||||
const result = await engine.evaluate(plainDevice)
|
||||
expect(result.configuration).not.toBeNull()
|
||||
})
|
||||
|
||||
// --- shouldApplyOnce: policy branches ---
|
||||
|
||||
it('shouldApplyOnce always applies for an unknown/unset policy', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ policy: 'weird-policy' })])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shouldApplyOnce re-applies when provisioningCode has an invalid format', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ policy: 'once' })])
|
||||
const device = makeDevice({ provisioningCode: 'not-a-valid-format' })
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.isAlreadyCompliant).toBe(false)
|
||||
})
|
||||
|
||||
it('shouldApplyOnce re-applies when provisioningCode identifier does not match config', async () => {
|
||||
const config = makeConfig({ policy: 'once', identifier: 'config-a' })
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
|
||||
|
||||
const { buildProvisioningCode } = await import('../../../app/utils/provisioning_code.js')
|
||||
const otherConfig = makeConfig({ policy: 'once', identifier: 'config-b' })
|
||||
const pcForOtherConfig = buildProvisioningCode(otherConfig as any)
|
||||
|
||||
const device = makeDevice({ provisioningCode: pcForOtherConfig })
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.isAlreadyCompliant).toBe(false)
|
||||
})
|
||||
|
||||
it('shouldApplyOnce re-applies when provisioningCode content hash changed', async () => {
|
||||
const config = makeConfig({ policy: 'once' })
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
|
||||
|
||||
// Build a PC for the SAME identifier/version but DIFFERENT content (stale hash)
|
||||
const staleConfig = makeConfig({
|
||||
policy: 'once',
|
||||
desiredParameters: [{ name: 'Device.Other.Param', value: 'x', type: 'string' }],
|
||||
})
|
||||
const { buildProvisioningCode } = await import('../../../app/utils/provisioning_code.js')
|
||||
const stalePC = buildProvisioningCode(staleConfig as any)
|
||||
|
||||
const device = makeDevice({ provisioningCode: stalePC })
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.isAlreadyCompliant).toBe(false)
|
||||
})
|
||||
|
||||
// --- getDeviceParameterValue: plain DeviceRecord fallback ---
|
||||
|
||||
it('computeParameterDiffs reads value via plain object fallback (no getParameter method)', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
desiredParameters: [{ name: 'Device.Plain.Param', value: '42', type: 'string' }],
|
||||
}),
|
||||
])
|
||||
const plainDevice: any = {
|
||||
deviceId: {
|
||||
manufacturer: 'Sagemcom',
|
||||
oui: 'F8B7FF',
|
||||
productClass: 'Livebox 4',
|
||||
serialNumber: 'LB4-TEST-001',
|
||||
},
|
||||
parameters: [{ name: 'Device.Plain.Param', value: '42' }],
|
||||
properties: [],
|
||||
}
|
||||
const result = await engine.evaluate(plainDevice)
|
||||
expect(result.parameterDiffs.length).toBe(0)
|
||||
})
|
||||
|
||||
it('computeParameterDiffs treats numeric value 0 correctly via plain object fallback', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
desiredParameters: [{ name: 'Device.Plain.Count', value: 0, type: 'string' }],
|
||||
}),
|
||||
])
|
||||
const plainDevice: any = {
|
||||
deviceId: {
|
||||
manufacturer: 'Sagemcom',
|
||||
oui: 'F8B7FF',
|
||||
productClass: 'Livebox 4',
|
||||
serialNumber: 'LB4-TEST-001',
|
||||
},
|
||||
parameters: [{ name: 'Device.Plain.Count', value: 0 }],
|
||||
properties: [],
|
||||
}
|
||||
const result = await engine.evaluate(plainDevice)
|
||||
expect(result.parameterDiffs.length).toBe(0)
|
||||
})
|
||||
|
||||
it('computeParameterDiffs treats boolean value via plain object fallback', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
desiredParameters: [{ name: 'Device.Plain.Flag', value: true, type: 'string' }],
|
||||
}),
|
||||
])
|
||||
const plainDevice: any = {
|
||||
deviceId: {
|
||||
manufacturer: 'Sagemcom',
|
||||
oui: 'F8B7FF',
|
||||
productClass: 'Livebox 4',
|
||||
serialNumber: 'LB4-TEST-001',
|
||||
},
|
||||
parameters: [{ name: 'Device.Plain.Flag', value: true }],
|
||||
properties: [],
|
||||
}
|
||||
const result = await engine.evaluate(plainDevice)
|
||||
expect(result.parameterDiffs.length).toBe(0)
|
||||
})
|
||||
|
||||
it('getDeviceParameterValue returns null when parameter is not found (Device instance)', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
desiredParameters: [{ name: 'Device.Not.There', value: 'x', type: 'string' }],
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.parameterDiffs[0].currentValue).toBeNull()
|
||||
})
|
||||
|
||||
// --- firmwareTarget / schedule window branches ---
|
||||
|
||||
it('skips firmware migration when outside the schedule window', async () => {
|
||||
const { isWithinSchedule } = await import('../../../app/utils/schedule_window.js')
|
||||
vi.mocked(isWithinSchedule).mockReturnValueOnce(false)
|
||||
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
firmwareTarget: { productClass: 'Livebox 4', targetVersion: '2.0.0' },
|
||||
schedule: { start: '00:00', end: '06:00' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.skipFirmware).toBe(true)
|
||||
expect(result.firmwareMigrationPath).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves firmware migration path when current version differs from target', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
firmwareTarget: { productClass: 'Livebox 4', targetVersion: '2.0.0' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
// getDeviceParameterValue for Device instances only checks getParameter(), not getProperty()
|
||||
device.setParameter(
|
||||
new DeviceParameter('Device.DeviceInfo.SoftwareVersion', '1.0.0', ParameterType.STRING)
|
||||
)
|
||||
|
||||
const firmwareModule = await import('../../../app/services/firmware_catalog_service.js')
|
||||
const FirmwareCatalogService = firmwareModule.default
|
||||
vi.spyOn(FirmwareCatalogService.prototype, 'resolveUpgradePath').mockResolvedValue([
|
||||
{ fromVersion: '1.0.0', toVersion: '2.0.0' } as any,
|
||||
])
|
||||
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.firmwareMigrationPath.length).toBe(1)
|
||||
expect(result.isAlreadyCompliant).toBe(false)
|
||||
})
|
||||
|
||||
it('does not resolve migration path when current version already matches target', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
firmwareTarget: { productClass: 'Livebox 4', targetVersion: '2.0.0' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
device.setParameter(
|
||||
new DeviceParameter('Device.DeviceInfo.SoftwareVersion', '2.0.0', ParameterType.STRING)
|
||||
)
|
||||
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.firmwareMigrationPath).toEqual([])
|
||||
})
|
||||
|
||||
it('getDeviceVersion returns null when no version parameter exists', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({
|
||||
firmwareTarget: { productClass: 'Livebox 4', targetVersion: '2.0.0' },
|
||||
}),
|
||||
])
|
||||
const device = makeDevice()
|
||||
// No SoftwareVersion set anywhere
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.firmwareMigrationPath).toEqual([])
|
||||
})
|
||||
|
||||
// --- dryRun ---
|
||||
|
||||
describe('dryRun', () => {
|
||||
it('throws when the configuration is not found', async () => {
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(null)
|
||||
await expect(engine.dryRun('missing-config', 'some-device')).rejects.toThrow(
|
||||
'Configuration missing-config not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the device is not found', async () => {
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(makeConfig() as any)
|
||||
vi.mocked(DeviceModel.findOne).mockResolvedValue(null)
|
||||
await expect(engine.dryRun('test-config', 'missing-device')).rejects.toThrow(
|
||||
'Device missing-device not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the configuration does not match the device criteria', async () => {
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(
|
||||
makeConfig({ criteria: { manufacturer: 'Huawei' } }) as any
|
||||
)
|
||||
vi.mocked(DeviceModel.findOne).mockResolvedValue(makeDevice() as any)
|
||||
await expect(engine.dryRun('test-config', 'some-device')).rejects.toThrow(
|
||||
/does not match device/
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a DryRunResult when configuration matches the device', async () => {
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(makeConfig() as any)
|
||||
vi.mocked(DeviceModel.findOne).mockResolvedValue(makeDevice() as any)
|
||||
|
||||
const result = await engine.dryRun('test-config', 'some-device')
|
||||
|
||||
expect(result.configurationId).toBe('test-config')
|
||||
expect(result.deviceIdentifier).toBe('some-device')
|
||||
expect(result.evaluation).toBeDefined()
|
||||
expect(result.timestamp).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('dryRun skips (isAlreadyCompliant) when shouldApplyOnce is false (evaluateSpecific)', async () => {
|
||||
const config = makeConfig({ policy: 'once' })
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any)
|
||||
|
||||
const { buildProvisioningCode } = await import('../../../app/utils/provisioning_code.js')
|
||||
const expectedPC = buildProvisioningCode(config as any)
|
||||
const device = makeDevice({ provisioningCode: expectedPC })
|
||||
vi.mocked(DeviceModel.findOne).mockResolvedValue(device as any)
|
||||
|
||||
const result = await engine.dryRun('test-config', 'some-device')
|
||||
|
||||
expect(result.evaluation.isAlreadyCompliant).toBe(true)
|
||||
expect(result.evaluation.parameterDiffs).toEqual([])
|
||||
})
|
||||
|
||||
it('dryRun skips firmware migration outside schedule window (evaluateSpecific)', async () => {
|
||||
const { isWithinSchedule } = await import('../../../app/utils/schedule_window.js')
|
||||
vi.mocked(isWithinSchedule).mockReturnValueOnce(false)
|
||||
|
||||
const config = makeConfig({
|
||||
firmwareTarget: { productClass: 'Livebox 4', targetVersion: '2.0.0' },
|
||||
schedule: { start: '00:00', end: '06:00' },
|
||||
})
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any)
|
||||
vi.mocked(DeviceModel.findOne).mockResolvedValue(makeDevice() as any)
|
||||
|
||||
const result = await engine.dryRun('test-config', 'some-device')
|
||||
|
||||
expect(result.evaluation.skipFirmware).toBe(true)
|
||||
expect(result.evaluation.firmwareMigrationPath).toEqual([])
|
||||
})
|
||||
|
||||
it('dryRun resolves firmware migration path when version differs (evaluateSpecific)', async () => {
|
||||
const config = makeConfig({
|
||||
firmwareTarget: { productClass: 'Livebox 4', targetVersion: '2.0.0' },
|
||||
})
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any)
|
||||
|
||||
const device = makeDevice()
|
||||
device.setParameter(
|
||||
new DeviceParameter('Device.DeviceInfo.SoftwareVersion', '1.0.0', ParameterType.STRING)
|
||||
)
|
||||
vi.mocked(DeviceModel.findOne).mockResolvedValue(device as any)
|
||||
|
||||
const firmwareModule = await import('../../../app/services/firmware_catalog_service.js')
|
||||
const FirmwareCatalogService = firmwareModule.default
|
||||
vi.spyOn(FirmwareCatalogService.prototype, 'resolveUpgradePath').mockResolvedValue([
|
||||
{ fromVersion: '1.0.0', toVersion: '2.0.0' } as any,
|
||||
])
|
||||
|
||||
const result = await engine.dryRun('test-config', 'some-device')
|
||||
|
||||
expect(result.evaluation.firmwareMigrationPath.length).toBe(1)
|
||||
expect(result.evaluation.isAlreadyCompliant).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- complianceReport ---
|
||||
|
||||
describe('complianceReport', () => {
|
||||
it('throws when the configuration is not found', async () => {
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(null)
|
||||
await expect(engine.complianceReport('missing-config')).rejects.toThrow(
|
||||
'Configuration missing-config not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns compliant/non-compliant counts for matching devices', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any)
|
||||
|
||||
const compliantDevice = makeDevice()
|
||||
compliantDevice.setParameter(
|
||||
new DeviceParameter(
|
||||
'Device.ManagementServer.PeriodicInformInterval',
|
||||
'300',
|
||||
ParameterType.UNSIGNED_INT
|
||||
)
|
||||
)
|
||||
const nonCompliantDevice = makeDevice()
|
||||
nonCompliantDevice.setParameter(
|
||||
new DeviceParameter(
|
||||
'Device.ManagementServer.PeriodicInformInterval',
|
||||
'60',
|
||||
ParameterType.UNSIGNED_INT
|
||||
)
|
||||
)
|
||||
const nonMatchingDevice = makeDevice({ manufacturer: 'Huawei' })
|
||||
|
||||
const mockCollection = {
|
||||
find: vi.fn(() => ({
|
||||
toArray: vi
|
||||
.fn()
|
||||
.mockResolvedValue([compliantDevice, nonCompliantDevice, nonMatchingDevice]),
|
||||
})),
|
||||
}
|
||||
vi.mocked(DeviceModel.getCollection).mockReturnValue(mockCollection as any)
|
||||
|
||||
const report = await engine.complianceReport('test-config')
|
||||
|
||||
expect(report.configurationId).toBe('test-config')
|
||||
expect(report.configurationName).toBe('Test Config')
|
||||
expect(report.totalDevices).toBe(2) // only matching devices
|
||||
expect(report.compliantCount).toBe(1)
|
||||
expect(report.nonCompliantCount).toBe(1)
|
||||
expect(report.devices).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns zero devices when none match the criteria', async () => {
|
||||
const config = makeConfig({ criteria: { manufacturer: 'NoSuchVendor' } })
|
||||
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any)
|
||||
|
||||
const mockCollection = {
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([makeDevice()]) })),
|
||||
}
|
||||
vi.mocked(DeviceModel.getCollection).mockReturnValue(mockCollection as any)
|
||||
|
||||
const report = await engine.complianceReport('test-config')
|
||||
|
||||
expect(report.totalDevices).toBe(0)
|
||||
expect(report.compliantCount).toBe(0)
|
||||
expect(report.nonCompliantCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,13 +9,14 @@ vi.mock('#models/device', () => ({
|
||||
updateOne: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
const sharedCommandCollection = {
|
||||
find: vi.fn().mockReturnValue({
|
||||
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}
|
||||
vi.mock('../../../../app/models/command.js', () => ({
|
||||
default: {
|
||||
getCollection: () => ({
|
||||
find: vi.fn().mockReturnValue({
|
||||
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
getCollection: vi.fn(() => sharedCommandCollection),
|
||||
updateCommand: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
@@ -63,15 +64,18 @@ function makeContext(device: Device) {
|
||||
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))
|
||||
vi.mocked(collection.find).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sort: () => ({
|
||||
toArray: () => {
|
||||
const result = callCount === 0 ? commands : []
|
||||
callCount++
|
||||
return Promise.resolve(result)
|
||||
},
|
||||
}),
|
||||
}) as any
|
||||
)
|
||||
}
|
||||
|
||||
describe('MainStateMachine', () => {
|
||||
@@ -96,7 +100,9 @@ describe('MainStateMachine', () => {
|
||||
// Simulate GPN success response → transitions to critical-reads (single path queue)
|
||||
await sm.handleResponse(true, {
|
||||
ParameterList: {
|
||||
ParameterInfoStruct: [{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }],
|
||||
ParameterInfoStruct: [
|
||||
{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' },
|
||||
],
|
||||
},
|
||||
})
|
||||
// Only one path in queue → transitions to critical-reads
|
||||
@@ -192,6 +198,347 @@ describe('ExecutingCommandsState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutingCommandsState.processNextCommand — command type dispatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function makeCommand(action: CommandType, parameters: any = {}) {
|
||||
return {
|
||||
_id: 'cmd-1',
|
||||
deviceIdentifier: 'hash-1',
|
||||
mode: ModeType.NI,
|
||||
action,
|
||||
parameters,
|
||||
status: CommandStatus.PENDING,
|
||||
priority: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
it('dispatches GET_PARAMETER_VALUES with a names array', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_VALUES, ['Device.Info.SW'])])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const device = makeDevice()
|
||||
const context = makeContext(device)
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('GetParameterValues')
|
||||
expect(result.response).toContain('Device.Info.SW')
|
||||
})
|
||||
|
||||
it('dispatches GET_PARAMETER_VALUES using the "paths" property fallback', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_VALUES, { paths: ['Device.Info.HW'] })])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('Device.Info.HW')
|
||||
})
|
||||
|
||||
it('dispatches GET_PARAMETER_NAMES with a string path', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_NAMES, 'Device.WiFi.')])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('GetParameterNames')
|
||||
expect(result.response).toContain('Device.WiFi.')
|
||||
})
|
||||
|
||||
it('dispatches GET_PARAMETER_NAMES using the "path" property fallback', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_NAMES, { path: 'Device.LAN.' })])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('Device.LAN.')
|
||||
})
|
||||
|
||||
it('dispatches GET_PARAMETER_ATTRIBUTES with a names array', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand(CommandType.GET_PARAMETER_ATTRIBUTES, ['Device.Info.Manufacturer']),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('GetParameterAttributes')
|
||||
})
|
||||
|
||||
it('dispatches SET_PARAMETER_VALUES with an array of {name, value}', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand(CommandType.SET_PARAMETER_VALUES, [
|
||||
{ name: 'Device.WiFi.SSID', value: 'MyWifi', type: 'xsd:string' },
|
||||
]),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('SetParameterValues')
|
||||
expect(result.response).toContain('MyWifi')
|
||||
})
|
||||
|
||||
it('dispatches SET_PARAMETER_VALUES using the "parameterValues" property fallback', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand(CommandType.SET_PARAMETER_VALUES, {
|
||||
parameterValues: [{ parameterName: 'Device.WiFi.Enable', value: true }],
|
||||
}),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('SetParameterValues')
|
||||
})
|
||||
|
||||
it('dispatches SET_PARAMETER_ATTRIBUTES', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand(CommandType.SET_PARAMETER_ATTRIBUTES, [
|
||||
{ name: 'Device.WiFi.SSID', notification: 1, accessList: [] },
|
||||
]),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('SetParameterAttributes')
|
||||
})
|
||||
|
||||
it('dispatches DOWNLOAD', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand(CommandType.DOWNLOAD, {
|
||||
fileType: '1 Firmware Upgrade Image',
|
||||
url: 'http://fw/image.bin',
|
||||
fileSize: 1000,
|
||||
}),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('Download')
|
||||
})
|
||||
|
||||
it('dispatches UPLOAD', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.UPLOAD, { fileType: '1 Vendor Configuration File' })])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('Upload')
|
||||
})
|
||||
|
||||
it('dispatches REBOOT', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('Reboot')
|
||||
})
|
||||
|
||||
it('dispatches FACTORY_RESET', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.FACTORY_RESET, {})])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('FactoryReset')
|
||||
})
|
||||
|
||||
it('dispatches SCHEDULE_INFORM with a default delay when none provided', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.SCHEDULE_INFORM, {})])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('ScheduleInform')
|
||||
})
|
||||
|
||||
it('dispatches REFRESH by re-reading all known non-empty leaf parameters', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.REFRESH, {})])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const device = makeDevice()
|
||||
device.setParameter(
|
||||
new DeviceParameter(
|
||||
'Device.ManagementServer.PeriodicInformInterval',
|
||||
'300',
|
||||
ParameterType.UNSIGNED_INT
|
||||
)
|
||||
)
|
||||
device.setProperty(
|
||||
new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING)
|
||||
)
|
||||
const context = makeContext(device)
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('GetParameterValues')
|
||||
expect(result.response).toContain('Device.ManagementServer.PeriodicInformInterval')
|
||||
expect(result.response).toContain('Device.DeviceInfo.Manufacturer')
|
||||
})
|
||||
|
||||
it('REFRESH returns empty string when the device has no known parameters', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.REFRESH, {})])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
// makeDevice() has no parameters/properties set beyond tmpParamValues
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toBe('')
|
||||
})
|
||||
|
||||
it('marks the command as FAILED and returns empty for an unknown command type', async () => {
|
||||
setupCommandMock([makeCommand('BOGUS_ACTION' as CommandType, {})])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toBe('')
|
||||
expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith(
|
||||
'cmd-1',
|
||||
expect.objectContaining({ status: CommandStatus.FAILED })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls through to the next command when an unknown type is followed by a known one', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand('BOGUS_ACTION' as CommandType, {}),
|
||||
makeCommand(CommandType.REBOOT, {}),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
expect(result.response).toContain('Reboot')
|
||||
})
|
||||
|
||||
it('processes command response: marks COMPLETED on success and advances to next command', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand(CommandType.REBOOT, {}),
|
||||
makeCommand(CommandType.FACTORY_RESET, {}),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
// First call: sends REBOOT
|
||||
await state.handle(context)
|
||||
// Second call: simulates a successful CWMP response for REBOOT, advances to FACTORY_RESET
|
||||
context.message = { success: true, data: {} }
|
||||
const result = await state.handle(context)
|
||||
|
||||
expect(result.response).toContain('FactoryReset')
|
||||
expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith(
|
||||
'cmd-1',
|
||||
expect.objectContaining({ status: CommandStatus.COMPLETED })
|
||||
)
|
||||
})
|
||||
|
||||
it('processes command response: marks FAILED on non-success and clears the queue if empty', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
||||
const state = new ExecutingCommandsState(false)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
await state.handle(context)
|
||||
context.message = { success: false, data: { FaultCode: 9001 } }
|
||||
const result = await state.handle(context)
|
||||
|
||||
expect(result.response).toBe('')
|
||||
expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith(
|
||||
'cmd-1',
|
||||
expect.objectContaining({ status: CommandStatus.FAILED })
|
||||
)
|
||||
})
|
||||
|
||||
it('optimistically applies SET_PARAMETER_VALUES on success (updateFromValues called)', async () => {
|
||||
setupCommandMock([
|
||||
makeCommand(CommandType.SET_PARAMETER_VALUES, [
|
||||
{ name: 'Device.WiFi.SSID', value: 'NewWifi', type: 'xsd:string' },
|
||||
]),
|
||||
])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const device = makeDevice()
|
||||
const context = makeContext(device)
|
||||
|
||||
await state.handle(context)
|
||||
context.message = { success: true, data: {} }
|
||||
await state.handle(context)
|
||||
|
||||
const param = device.getParameter('Device.WiFi.SSID') || device.getProperty('Device.WiFi.SSID')
|
||||
expect(param?.value).toBe('NewWifi')
|
||||
})
|
||||
|
||||
it('all commands done with includeNI=true transitions to OperationalState', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
await state.handle(context)
|
||||
context.message = { success: true, data: {} }
|
||||
const result = await state.handle(context)
|
||||
|
||||
expect(result.nextState?.name).toBe('operational')
|
||||
})
|
||||
|
||||
it('all commands done with includeNI=false completes without transitioning (immediate machine)', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
||||
const state = new ExecutingCommandsState(false)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
await state.handle(context)
|
||||
context.message = { success: true, data: {} }
|
||||
const result = await state.handle(context)
|
||||
|
||||
expect(result.nextState).toBeUndefined()
|
||||
expect(result.response).toBe('')
|
||||
})
|
||||
|
||||
it('tolerates CommandModel.updateCommand rejecting when marking SENT', async () => {
|
||||
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
||||
vi.mocked(CommandModel.updateCommand).mockRejectedValueOnce(new Error('db error'))
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const context = makeContext(makeDevice())
|
||||
|
||||
const result = await state.handle(context)
|
||||
// Should not throw — error is caught and logged
|
||||
expect(result.response).toContain('Reboot')
|
||||
})
|
||||
|
||||
// --- Exercising the no-op completion callbacks registered per command type ---
|
||||
// Each dispatch registers a `() => Promise.resolve('')` callback with the task
|
||||
// queue; it only runs when the CWMP task lifecycle completes it.
|
||||
|
||||
it.each([
|
||||
[CommandType.GET_PARAMETER_VALUES, ['Device.Info.SW']],
|
||||
[CommandType.GET_PARAMETER_NAMES, 'Device.WiFi.'],
|
||||
[CommandType.GET_PARAMETER_ATTRIBUTES, ['Device.Info.Manufacturer']],
|
||||
[CommandType.SET_PARAMETER_VALUES, [{ name: 'Device.WiFi.SSID', value: 'x' }]],
|
||||
[
|
||||
CommandType.SET_PARAMETER_ATTRIBUTES,
|
||||
[{ name: 'Device.WiFi.SSID', notification: 1, accessList: [] }],
|
||||
],
|
||||
[CommandType.DOWNLOAD, { fileType: '1 Firmware Upgrade Image', url: 'http://fw/x.bin' }],
|
||||
[CommandType.UPLOAD, { fileType: '1 Vendor Configuration File' }],
|
||||
[CommandType.REBOOT, {}],
|
||||
[CommandType.FACTORY_RESET, {}],
|
||||
[CommandType.SCHEDULE_INFORM, {}],
|
||||
])('completes the no-op task callback for %s without throwing', async (action, parameters) => {
|
||||
setupCommandMock([makeCommand(action as CommandType, parameters)])
|
||||
const state = new ExecutingCommandsState(true)
|
||||
const device = makeDevice()
|
||||
const context = makeContext(device)
|
||||
|
||||
await state.handle(context)
|
||||
|
||||
const taskId = context.taskQueue.getLastCreatedTaskId(device.deviceId)
|
||||
expect(taskId).toBeDefined()
|
||||
|
||||
const response = await context.taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {})
|
||||
expect(response).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN flows)', () => {
|
||||
beforeEach(() => {
|
||||
@@ -202,7 +549,9 @@ describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN f
|
||||
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/' } }])
|
||||
device.setTmpParamValues([
|
||||
{ Name: 'InternetGatewayDevice.ManagementServer.URL', Value: { _: 'http://acs/' } },
|
||||
])
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
||||
|
||||
@@ -250,6 +599,68 @@ describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN f
|
||||
const response = await sm.start()
|
||||
expect(response).toBeDefined()
|
||||
})
|
||||
|
||||
it('forces max-transitions-exceeded and transitions to PartialOperationalState', async () => {
|
||||
const device = makeDevice('unsupported')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
||||
|
||||
// Inject a state that always transitions to a fresh instance of itself,
|
||||
// never producing a response nor completing — forces the 15-iteration guard.
|
||||
class LoopingState {
|
||||
name = 'looping'
|
||||
async handle() {
|
||||
return { nextState: new LoopingState() as any }
|
||||
}
|
||||
}
|
||||
;(sm as any).currentState = new LoopingState()
|
||||
|
||||
const response = await sm.start()
|
||||
|
||||
expect(response).toBe('')
|
||||
expect(sm.getCurrentStateName()).toBe('partial_operational')
|
||||
expect(sm.isCompleted()).toBe(true)
|
||||
expect(device.lastFailureReason).toBe('max_transitions_exceeded')
|
||||
})
|
||||
|
||||
it('transitions to PartialOperationalState when a state handler throws', async () => {
|
||||
const device = makeDevice('unsupported')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
||||
|
||||
class ThrowingState {
|
||||
name = 'throwing'
|
||||
async handle(): Promise<any> {
|
||||
throw new Error('boom in state handler')
|
||||
}
|
||||
}
|
||||
;(sm as any).currentState = new ThrowingState()
|
||||
|
||||
const response = await sm.start()
|
||||
|
||||
expect(response).toBe('')
|
||||
expect(sm.getCurrentStateName()).toBe('partial_operational')
|
||||
expect(device.lastFailureReason).toBe('unrecoverable_error')
|
||||
})
|
||||
|
||||
it('returns empty string when a state produces neither response, nextState, nor isComplete', async () => {
|
||||
const device = makeDevice('unsupported')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
||||
|
||||
class DeadEndState {
|
||||
name = 'dead-end'
|
||||
async handle(): Promise<any> {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
;(sm as any).currentState = new DeadEndState()
|
||||
|
||||
const response = await sm.start()
|
||||
|
||||
expect(response).toBe('')
|
||||
expect(sm.getCurrentStateName()).toBe('dead-end')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OperationalState', () => {
|
||||
@@ -276,6 +687,24 @@ describe('OperationalState', () => {
|
||||
expect.objectContaining({ operationalStatus: 'operational' })
|
||||
)
|
||||
})
|
||||
|
||||
it('still completes when DeviceModel.createOrUpdate rejects (persistence error tolerated)', async () => {
|
||||
const device = makeDevice()
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const context = {
|
||||
device,
|
||||
session: 'test-session',
|
||||
taskQueue,
|
||||
services: { deviceAction: {} as any },
|
||||
}
|
||||
vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('db unavailable'))
|
||||
|
||||
const state = new OperationalState()
|
||||
const result = await state.handle(context as any)
|
||||
|
||||
expect(result.isComplete).toBe(true)
|
||||
expect(device.operationalStatus).toBe('operational')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PartialOperationalState', () => {
|
||||
@@ -309,6 +738,22 @@ describe('PartialOperationalState', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('still completes when DeviceModel.createOrUpdate rejects (persistence error tolerated)', async () => {
|
||||
const device = makeDevice()
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const context = {
|
||||
device,
|
||||
session: 'test-session',
|
||||
taskQueue,
|
||||
services: { deviceAction: {} as any },
|
||||
}
|
||||
vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('db unavailable'))
|
||||
|
||||
const state = new PartialOperationalState(device, { reason: 'test_failure' })
|
||||
const result = await state.handle(context as any)
|
||||
|
||||
expect(result.isComplete).toBe(true)
|
||||
expect(device.operationalStatus).toBe('partial_operational')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,17 +5,29 @@ vi.mock('@adonisjs/core/services/logger', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../../../app/models/device.js', () => ({
|
||||
default: { createOrUpdate: vi.fn().mockResolvedValue({}), findByDeviceId: vi.fn().mockResolvedValue(null), updateOne: vi.fn().mockResolvedValue({}) },
|
||||
default: {
|
||||
createOrUpdate: vi.fn().mockResolvedValue({}),
|
||||
findByDeviceId: vi.fn().mockResolvedValue(null),
|
||||
updateOne: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
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) }),
|
||||
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 {
|
||||
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 { CWMPTaskQueue, ResultStatus } from '../../../../app/services/cwmp/task_queue.js'
|
||||
@@ -49,7 +61,9 @@ describe('StateMachineManager — coverage', () => {
|
||||
|
||||
it('routes CONNECTION_REQUEST to ImmediateCommandStateMachine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-2', { events: ['6 CONNECTION REQUEST'] })
|
||||
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')
|
||||
})
|
||||
@@ -66,7 +80,9 @@ describe('StateMachineManager — coverage', () => {
|
||||
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'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-4', {
|
||||
events: ['6 CONNECTION REQUEST'],
|
||||
})
|
||||
// Should be MainStateMachine (idle) not ImmediateCommandStateMachine
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBe('idle')
|
||||
})
|
||||
@@ -74,7 +90,9 @@ describe('StateMachineManager — coverage', () => {
|
||||
it('preempts ImmediateCommandStateMachine on BOOT event with same session', async () => {
|
||||
const device = makeDevice()
|
||||
// First: create ImmediateCommandStateMachine
|
||||
const machine1 = await manager.startStateMachine(device, 'sess-5', { events: ['6 CONNECTION REQUEST'] })
|
||||
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 (same TCP connection, new Inform)
|
||||
@@ -128,10 +146,16 @@ describe('StateMachineManager — coverage', () => {
|
||||
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() })
|
||||
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: [] } })
|
||||
await manager.handleCWMPResponse('sess-8', true, {
|
||||
ParameterList: { ParameterValueStruct: [] },
|
||||
})
|
||||
|
||||
// Callback should have been invoked
|
||||
expect(callbackFn).toHaveBeenCalled()
|
||||
@@ -190,7 +214,6 @@ describe('StateMachineManager — coverage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('StateMachineManager — additional coverage', () => {
|
||||
let manager: StateMachineManager
|
||||
|
||||
@@ -204,20 +227,26 @@ describe('StateMachineManager — additional coverage', () => {
|
||||
|
||||
it('routes VALUE_CHANGE to MainStateMachine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-vc', { events: ['4 VALUE CHANGE'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-vc', {
|
||||
events: ['4 VALUE CHANGE'],
|
||||
})
|
||||
expect(machine.status).toBe(MachineStatus.RUNNING)
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBeDefined()
|
||||
})
|
||||
|
||||
it('routes DIAGNOSTICS_COMPLETE to ImmediateCommandStateMachine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-dc', { events: ['8 DIAGNOSTICS COMPLETE'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-dc', {
|
||||
events: ['8 DIAGNOSTICS COMPLETE'],
|
||||
})
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands')
|
||||
})
|
||||
|
||||
it('routes TRANSFER_COMPLETE to ImmediateCommandStateMachine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-tc', { events: ['7 TRANSFER COMPLETE'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-tc', {
|
||||
events: ['7 TRANSFER COMPLETE'],
|
||||
})
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands')
|
||||
})
|
||||
|
||||
@@ -253,14 +282,23 @@ describe('StateMachineManager — additional coverage', () => {
|
||||
|
||||
it('handleCWMPResponse calls failTask on fault response when lastTaskId set', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-fault', { events: ['0 BOOTSTRAP'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-fault', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
await manager.continue('sess-fault')
|
||||
|
||||
// Set lastTaskId
|
||||
const taskId = 'task-fail-test'
|
||||
machine.lastTaskId = taskId
|
||||
const taskQueue = manager.getTaskQueue()
|
||||
;(taskQueue as any).tasks.set(taskId, { id: taskId, deviceId: device.deviceId, type: 'test', payload: {}, status: 'SENT', createdAt: new Date() })
|
||||
;(taskQueue as any).tasks.set(taskId, {
|
||||
id: taskId,
|
||||
deviceId: device.deviceId,
|
||||
type: 'test',
|
||||
payload: {},
|
||||
status: 'SENT',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
;(taskQueue as any).taskCallbacks.set(taskId, vi.fn().mockResolvedValue(''))
|
||||
|
||||
await manager.handleCWMPResponse('sess-fault', false, { FaultCode: 9001 })
|
||||
@@ -284,11 +322,15 @@ describe('StateMachineManager — additional coverage', () => {
|
||||
|
||||
it('new CONNECTION_REQUEST completes old immediate machine and creates fresh', async () => {
|
||||
const device = makeDevice()
|
||||
const machine1 = await manager.startStateMachine(device, 'sess-cr1', { events: ['6 CONNECTION REQUEST'] })
|
||||
const machine1 = await manager.startStateMachine(device, 'sess-cr1', {
|
||||
events: ['6 CONNECTION REQUEST'],
|
||||
})
|
||||
expect(machine1.status).toBe(MachineStatus.RUNNING)
|
||||
|
||||
// New CONNECTION REQUEST on different session
|
||||
const machine2 = await manager.startStateMachine(device, 'sess-cr2', { events: ['6 CONNECTION REQUEST'] })
|
||||
const machine2 = await manager.startStateMachine(device, 'sess-cr2', {
|
||||
events: ['6 CONNECTION REQUEST'],
|
||||
})
|
||||
expect(machine1.status).toBe(MachineStatus.COMPLETED)
|
||||
expect(machine2.session).toBe('sess-cr2')
|
||||
})
|
||||
@@ -350,7 +392,9 @@ describe('StateMachineManager — error and edge case branches', () => {
|
||||
|
||||
it('handleCWMPResponse catches state machine errors and fails machine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-err2', { events: ['0 BOOTSTRAP'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-err2', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const result = await manager.handleCWMPResponse('sess-err2', true, {})
|
||||
@@ -360,7 +404,9 @@ describe('StateMachineManager — error and edge case branches', () => {
|
||||
|
||||
it('continue catches errors and fails machine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-cerr2', { events: ['0 BOOTSTRAP'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-cerr2', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('crash'))
|
||||
|
||||
const result = await manager.continue('sess-cerr2')
|
||||
@@ -370,7 +416,9 @@ describe('StateMachineManager — error and edge case branches', () => {
|
||||
|
||||
it('ensureSessionForRemote returns false for non-RUNNING machine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-enr2', { events: ['0 BOOTSTRAP'] })
|
||||
const machine = await manager.startStateMachine(device, 'sess-enr2', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
manager.bindRemoteToDevice('remote-xx', device.deviceId)
|
||||
;(machine as any).status = MachineStatus.COMPLETED
|
||||
|
||||
@@ -382,9 +430,138 @@ describe('StateMachineManager — error and edge case branches', () => {
|
||||
;(manager as any).remoteToDevice.set('bad-remote', 'not-valid-json{')
|
||||
expect(manager.getHashIdForRemote('bad-remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('startStateMachine tolerates errors while checking NI commands (falls back to non-forced routing)', async () => {
|
||||
const collection = CommandModel.getCollection()
|
||||
vi.mocked(collection.countDocuments).mockRejectedValueOnce(new Error('db down'))
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-nierr', {
|
||||
events: ['6 CONNECTION REQUEST'],
|
||||
})
|
||||
// Falls back to normal routing (ImmediateCommandStateMachine for CONNECTION REQUEST)
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StateMachineManager — getHashIdForRemoteIp', () => {
|
||||
let manager: StateMachineManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(StateMachineManager as any).instance = null
|
||||
manager = StateMachineManager.getInstance()
|
||||
})
|
||||
|
||||
it('returns the hash id for a single unambiguous RUNNING device matching the IP', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-ip1', { events: ['0 BOOTSTRAP'] })
|
||||
manager.bindRemoteToDevice('10.0.0.5:12345', device.deviceId)
|
||||
|
||||
const hash = manager.getHashIdForRemoteIp('10.0.0.5')
|
||||
expect(hash).toBeDefined()
|
||||
expect(typeof hash).toBe('string')
|
||||
})
|
||||
|
||||
it('matches IPv6 addresses with brackets stripped', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-ip6', { events: ['0 BOOTSTRAP'] })
|
||||
manager.bindRemoteToDevice('[::1]:7547', device.deviceId)
|
||||
|
||||
const hash = manager.getHashIdForRemoteIp('::1')
|
||||
expect(hash).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns undefined when zero devices match the IP', () => {
|
||||
expect(manager.getHashIdForRemoteIp('9.9.9.9')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the matching device has no RUNNING machine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-ipdone', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
manager.bindRemoteToDevice('10.0.0.9:1', device.deviceId)
|
||||
;(machine as any).status = MachineStatus.COMPLETED
|
||||
|
||||
expect(manager.getHashIdForRemoteIp('10.0.0.9')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when multiple RUNNING devices match the same IP (ambiguous NAT/CGNAT)', async () => {
|
||||
const device1 = makeDevice()
|
||||
const device2 = new (device1.constructor as any)(
|
||||
new (Object.getPrototypeOf(device1.deviceId).constructor)(
|
||||
'Sagemcom',
|
||||
'AABBCC',
|
||||
'Other',
|
||||
'SN2'
|
||||
)
|
||||
)
|
||||
await manager.startStateMachine(device1, 'sess-amb1', { events: ['0 BOOTSTRAP'] })
|
||||
await manager.startStateMachine(device2, 'sess-amb2', { events: ['0 BOOTSTRAP'] })
|
||||
manager.bindRemoteToDevice('10.0.0.7:1', device1.deviceId)
|
||||
manager.bindRemoteToDevice('10.0.0.7:2', device2.deviceId)
|
||||
|
||||
expect(manager.getHashIdForRemoteIp('10.0.0.7')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for invalid JSON in the single-candidate path', async () => {
|
||||
;(manager as any).remoteToDevice.set('10.0.0.8:1', 'not-valid-json{')
|
||||
;(manager as any).deviceSessions.set('not-valid-json{', 'fake-session')
|
||||
;(manager as any).sessionMachines.set('fake-session', 'fake-machine-id')
|
||||
;(manager as any).machines.set('fake-machine-id', { status: MachineStatus.RUNNING })
|
||||
|
||||
expect(manager.getHashIdForRemoteIp('10.0.0.8')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StateMachineManager — ensureSessionForRemote IP fallback', () => {
|
||||
let manager: StateMachineManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(StateMachineManager as any).instance = null
|
||||
manager = StateMachineManager.getInstance()
|
||||
})
|
||||
|
||||
it('rebinds via IP-only fallback when the exact remoteKey is unknown (new TCP port)', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-fb1', { events: ['0 BOOTSTRAP'] })
|
||||
manager.bindRemoteToDevice('10.0.0.20:1000', device.deviceId)
|
||||
|
||||
// CPE reconnects on a NEW port — exact remoteKey lookup will miss
|
||||
const result = manager.ensureSessionForRemote('10.0.0.20:2000', 'sess-fb1-new')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(manager.hasSession('sess-fb1-new')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when IP fallback is ambiguous (multiple RUNNING devices share the IP)', async () => {
|
||||
const device1 = makeDevice()
|
||||
const device2 = new (device1.constructor as any)(
|
||||
new (Object.getPrototypeOf(device1.deviceId).constructor)(
|
||||
'Sagemcom',
|
||||
'AABBCC',
|
||||
'Other',
|
||||
'SN3'
|
||||
)
|
||||
)
|
||||
await manager.startStateMachine(device1, 'sess-fb2a', { events: ['0 BOOTSTRAP'] })
|
||||
await manager.startStateMachine(device2, 'sess-fb2b', { events: ['0 BOOTSTRAP'] })
|
||||
manager.bindRemoteToDevice('10.0.0.21:1', device1.deviceId)
|
||||
manager.bindRemoteToDevice('10.0.0.21:2', device2.deviceId)
|
||||
|
||||
const result = manager.ensureSessionForRemote('10.0.0.21:3000', 'sess-fb2-new')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the resolved device has no existing session', async () => {
|
||||
const device = makeDevice()
|
||||
manager.bindRemoteToDevice('10.0.0.22:1', device.deviceId)
|
||||
// No startStateMachine call → no deviceSessions entry
|
||||
const result = manager.ensureSessionForRemote('10.0.0.22:9999', 'sess-orphan')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StateMachineManager — stale session replacement', () => {
|
||||
let manager: StateMachineManager
|
||||
@@ -400,12 +577,16 @@ describe('StateMachineManager — stale session replacement', () => {
|
||||
const device = makeDevice()
|
||||
|
||||
// Start first machine on session-A
|
||||
const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] })
|
||||
const machine1 = await manager.startStateMachine(device, 'session-A', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
expect(machine1.status).toBe(MachineStatus.RUNNING)
|
||||
expect(machine1.session).toBe('session-A')
|
||||
|
||||
// Start second machine on session-B (different session, same device)
|
||||
const machine2 = await manager.startStateMachine(device, 'session-B', { events: ['2 PERIODIC'] })
|
||||
const machine2 = await manager.startStateMachine(device, 'session-B', {
|
||||
events: ['2 PERIODIC'],
|
||||
})
|
||||
|
||||
// Should be a NEW machine (not the old one rebinding)
|
||||
expect(machine2.session).toBe('session-B')
|
||||
@@ -417,10 +598,14 @@ describe('StateMachineManager — stale session replacement', () => {
|
||||
const device = makeDevice()
|
||||
|
||||
// Start machine on session-A
|
||||
const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] })
|
||||
const machine1 = await manager.startStateMachine(device, 'session-A', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
|
||||
// Start again on SAME session
|
||||
const machine2 = await manager.startStateMachine(device, 'session-A', { events: ['2 PERIODIC'] })
|
||||
const machine2 = await manager.startStateMachine(device, 'session-A', {
|
||||
events: ['2 PERIODIC'],
|
||||
})
|
||||
|
||||
// Should return the same machine (rebinding)
|
||||
expect(machine2.id).toBe(machine1.id)
|
||||
@@ -434,7 +619,9 @@ describe('StateMachineManager — stale session replacement', () => {
|
||||
await manager.startStateMachine(device, 'session-old', { events: ['0 BOOTSTRAP'] })
|
||||
|
||||
// Start new machine with different session
|
||||
const newMachine = await manager.startStateMachine(device, 'session-new', { events: ['2 PERIODIC'] })
|
||||
const newMachine = await manager.startStateMachine(device, 'session-new', {
|
||||
events: ['2 PERIODIC'],
|
||||
})
|
||||
|
||||
// Fresh machine should be running
|
||||
expect(newMachine.status).toBe(MachineStatus.RUNNING)
|
||||
@@ -442,5 +629,124 @@ describe('StateMachineManager — stale session replacement', () => {
|
||||
// Should be a MainStateMachine (PERIODIC event triggers MainStateMachine)
|
||||
expect(newMachine.currentState).toBeDefined()
|
||||
})
|
||||
|
||||
it('preempts an ImmediateCommandStateMachine and rebinds when the incoming session differs', async () => {
|
||||
const device = makeDevice()
|
||||
// Start an Immediate machine on session-imm1
|
||||
await manager.startStateMachine(device, 'session-imm1', { events: ['6 CONNECTION REQUEST'] })
|
||||
|
||||
// A BOOT event arrives on a DIFFERENT session while the immediate machine is still RUNNING
|
||||
const preempted = await manager.startStateMachine(device, 'session-imm2', {
|
||||
events: ['1 BOOT'],
|
||||
})
|
||||
|
||||
// Preemption rebinds to the new session and swaps in a MainStateMachine
|
||||
expect(preempted.session).toBe('session-imm2')
|
||||
expect(manager.hasSession('session-imm2')).toBe(true)
|
||||
expect(manager.hasSession('session-imm1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StateMachineManager — remote unbinding on completion', () => {
|
||||
let manager: StateMachineManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(StateMachineManager as any).instance = null
|
||||
manager = StateMachineManager.getInstance()
|
||||
})
|
||||
|
||||
it('unbinds all remoteKeys for a device when its machine is completed via preemption', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-unbind', { events: ['6 CONNECTION REQUEST'] })
|
||||
manager.bindRemoteToDevice('10.1.1.1:100', device.deviceId)
|
||||
manager.bindRemoteToDevice('10.1.1.1:200', device.deviceId)
|
||||
|
||||
expect(manager.getHashIdForRemote('10.1.1.1:100')).toBeDefined()
|
||||
|
||||
// A second CONNECTION REQUEST on a new session completes the first immediate
|
||||
// machine (via the explicit preemption path), which triggers unbindRemotesForDevice.
|
||||
await manager.startStateMachine(device, 'sess-unbind-2', { events: ['6 CONNECTION REQUEST'] })
|
||||
|
||||
expect(manager.getHashIdForRemote('10.1.1.1:100')).toBeUndefined()
|
||||
expect(manager.getHashIdForRemote('10.1.1.1:200')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StateMachineManager — continue()/handleCWMPResponse callback error paths', () => {
|
||||
let manager: StateMachineManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(StateMachineManager as any).instance = null
|
||||
manager = StateMachineManager.getInstance()
|
||||
})
|
||||
|
||||
it('continue() logs but does not throw when the empty-body failure task callback rejects', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-cberr', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
machine.lastTaskId = 'task-cb-err'
|
||||
const taskQueue = manager.getTaskQueue()
|
||||
;(taskQueue as any).tasks.set('task-cb-err', {
|
||||
id: 'task-cb-err',
|
||||
deviceId: device.deviceId,
|
||||
type: 'get_parameter_values',
|
||||
payload: [],
|
||||
status: 'SENT',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
;(taskQueue as any).taskCallbacks.set(
|
||||
'task-cb-err',
|
||||
vi.fn().mockRejectedValue(new Error('callback boom'))
|
||||
)
|
||||
|
||||
const response = await manager.continue('sess-cberr')
|
||||
// Should not throw — error is caught and logged; state machine still advances
|
||||
expect(typeof response).toBe('string')
|
||||
})
|
||||
|
||||
it('handleCWMPResponse logs but does not throw when the task callback rejects', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-hcberr', {
|
||||
events: ['0 BOOTSTRAP'],
|
||||
})
|
||||
await manager.continue('sess-hcberr')
|
||||
|
||||
machine.lastTaskId = 'task-hcb-err'
|
||||
const taskQueue = manager.getTaskQueue()
|
||||
;(taskQueue as any).tasks.set('task-hcb-err', {
|
||||
id: 'task-hcb-err',
|
||||
deviceId: device.deviceId,
|
||||
type: 'get_parameter_values',
|
||||
payload: [],
|
||||
status: 'SENT',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
;(taskQueue as any).taskCallbacks.set(
|
||||
'task-hcb-err',
|
||||
vi.fn().mockRejectedValue(new Error('callback boom'))
|
||||
)
|
||||
|
||||
const response = await manager.handleCWMPResponse('sess-hcberr', true, {
|
||||
ParameterList: { ParameterValueStruct: [] },
|
||||
})
|
||||
expect(typeof response).toBe('string')
|
||||
})
|
||||
|
||||
it('continue() serves a pending queued task using its message builder', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-queued', { events: ['2 PERIODIC'] })
|
||||
// Drain the machine's own response first
|
||||
await manager.continue('sess-queued')
|
||||
|
||||
// Manually enqueue an unrelated task for the device so continue() falls through
|
||||
// to "serve queued tasks" branch when the state machine itself returns empty.
|
||||
manager.getTaskQueue().addCWMPTask(device.deviceId, 'get_parameter_values', ['Device.Info.SW'])
|
||||
|
||||
const response = await manager.continue('sess-queued')
|
||||
// Either the state machine already produced a response, or the queued task did.
|
||||
expect(typeof response).toBe('string')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import DeviceService from '../../../app/services/device_service.js'
|
||||
import type { DeviceRecord } from '../../../app/domain/device/device_record.js'
|
||||
import { CommandStatus, CommandType, Priority } 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'
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,7 +36,12 @@ vi.mock('../../../app/models/command.js', () => ({
|
||||
function makeDevice(overrides: Partial<DeviceRecord> = {}): DeviceRecord {
|
||||
return {
|
||||
identifier: 'TestMfr-AABBCC-TestBox-SN001',
|
||||
deviceId: { manufacturer: 'TestMfr', oui: 'AABBCC', productClass: 'TestBox', serialNumber: 'SN001' },
|
||||
deviceId: {
|
||||
manufacturer: 'TestMfr',
|
||||
oui: 'AABBCC',
|
||||
productClass: 'TestBox',
|
||||
serialNumber: 'SN001',
|
||||
},
|
||||
properties: [],
|
||||
parameters: [],
|
||||
...overrides,
|
||||
@@ -66,7 +75,10 @@ describe('DeviceService — sendWakeOnWlan / Connection Request', () => {
|
||||
it('retourne true quand le CPE répond 200 sur une URL IPv4', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [
|
||||
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://192.168.1.100:7547/cr'),
|
||||
makeProperty(
|
||||
'Device.ManagementServer.ConnectionRequestURL',
|
||||
'http://192.168.1.100:7547/cr'
|
||||
),
|
||||
makeProperty('Device.ManagementServer.Username', 'admin'),
|
||||
makeProperty('Device.ManagementServer.Password', 'secret'),
|
||||
],
|
||||
@@ -87,31 +99,31 @@ describe('DeviceService — sendWakeOnWlan / Connection Request', () => {
|
||||
|
||||
it('retourne false et log error quand le CPE répond 401', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/')],
|
||||
properties: [
|
||||
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/'),
|
||||
],
|
||||
})
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' })
|
||||
|
||||
const result = await (service as any).sendWakeOnWlan(
|
||||
new URL('http://10.0.0.1:7547/'),
|
||||
device
|
||||
)
|
||||
const result = await (service as any).sendWakeOnWlan(new URL('http://10.0.0.1:7547/'), device)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('retourne false quand fetch lève une erreur réseau IPv4', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/')],
|
||||
properties: [
|
||||
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/'),
|
||||
],
|
||||
})
|
||||
|
||||
const networkError = Object.assign(new Error('fetch failed'), { cause: { code: 'ECONNREFUSED' } })
|
||||
const networkError = Object.assign(new Error('fetch failed'), {
|
||||
cause: { code: 'ECONNREFUSED' },
|
||||
})
|
||||
fetchMock.mockRejectedValue(networkError)
|
||||
|
||||
const result = await (service as any).sendWakeOnWlan(
|
||||
new URL('http://10.0.0.1:7547/'),
|
||||
device
|
||||
)
|
||||
const result = await (service as any).sendWakeOnWlan(new URL('http://10.0.0.1:7547/'), device)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
@@ -156,7 +168,7 @@ describe('DeviceService — sendWakeOnWlan / Connection Request', () => {
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://[::1]:8080/wake')
|
||||
})
|
||||
|
||||
it('retourne false et ENETUNREACH est loggé quand l\'IPv6 n\'est pas joignable', async () => {
|
||||
it("retourne false et ENETUNREACH est loggé quand l'IPv6 n'est pas joignable", async () => {
|
||||
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:50895/cr'
|
||||
const device = makeDevice({
|
||||
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
||||
@@ -261,4 +273,447 @@ describe('DeviceService — sendWakeOnWlan / Connection Request', () => {
|
||||
expect(decoded).toBe('admin:')
|
||||
})
|
||||
})
|
||||
|
||||
// ── IPv4 fallback (lastRemoteIp) ─────────────────────────────────────────────
|
||||
|
||||
describe('Fallback IPv4 via lastRemoteIp', () => {
|
||||
it('essaie le fallback IPv4 quand la première URL (IPv6) échoue', async () => {
|
||||
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:7547/cr'
|
||||
const device = makeDevice({
|
||||
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
||||
lastRemoteIp: '203.0.113.5',
|
||||
} as any)
|
||||
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('fetch failed'), { cause: { code: 'ENETUNREACH' } })
|
||||
)
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
|
||||
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('http://203.0.113.5:7547/cr')
|
||||
})
|
||||
|
||||
it('formatte le fallback IPv6 entre crochets si lastRemoteIp est une IPv6', async () => {
|
||||
const originalUrl = 'http://192.0.2.1:7547/cr'
|
||||
const device = makeDevice({
|
||||
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', originalUrl)],
|
||||
lastRemoteIp: '2001:db8::1',
|
||||
} as any)
|
||||
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
|
||||
await (service as any).sendWakeOnWlan(new URL(originalUrl), device)
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('http://[2001:db8::1]:7547/cr')
|
||||
})
|
||||
|
||||
it('ne tente pas de fallback si lastRemoteIp est identique au hostname original', async () => {
|
||||
const url = 'http://192.0.2.1:7547/cr'
|
||||
const device = makeDevice({
|
||||
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', url)],
|
||||
lastRemoteIp: '192.0.2.1',
|
||||
} as any)
|
||||
|
||||
fetchMock.mockRejectedValue(new Error('fetch failed'))
|
||||
|
||||
const result = await (service as any).sendWakeOnWlan(new URL(url), device)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retourne false si les deux URLs (originale + fallback) échouent', async () => {
|
||||
const ipv6Url = 'http://[2a01:cb15::1]:7547/cr'
|
||||
const device = makeDevice({
|
||||
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
||||
lastRemoteIp: '203.0.113.9',
|
||||
} as any)
|
||||
|
||||
fetchMock.mockRejectedValue(new Error('fetch failed'))
|
||||
|
||||
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── applyImmediateCommandForDevice (via executeCommand mode I) ──────────────
|
||||
|
||||
describe('applyImmediateCommandForDevice', () => {
|
||||
it('ne fait rien et log une erreur si ConnectionRequestURL est absent', async () => {
|
||||
const device = makeDevice({ properties: [] })
|
||||
|
||||
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('log une erreur et ne fetch pas si ConnectionRequestURL est une URL invalide', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [
|
||||
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'not-a-valid-url'),
|
||||
],
|
||||
})
|
||||
|
||||
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appelle sendWakeOnWlan puis met à jour le statut des commandes en attente', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [
|
||||
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
||||
],
|
||||
})
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
||||
|
||||
const collection = {
|
||||
find: vi.fn(() => ({
|
||||
toArray: vi.fn().mockResolvedValue([{ _id: 'cmd-1' }, { _id: 'cmd-2' }]),
|
||||
})),
|
||||
updateOne: vi.fn(),
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
|
||||
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
||||
|
||||
expect(collection.updateOne).toHaveBeenCalledTimes(2)
|
||||
expect(collection.updateOne).toHaveBeenCalledWith(
|
||||
{ _id: 'cmd-1' },
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({ status: CommandStatus.WAITING }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('met les commandes en PENDING (pas WAITING) si la Connection Request échoue', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [
|
||||
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
||||
],
|
||||
})
|
||||
fetchMock.mockRejectedValue(new Error('fetch failed'))
|
||||
|
||||
const collection = {
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([{ _id: 'cmd-1' }]) })),
|
||||
updateOne: vi.fn(),
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
|
||||
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
||||
|
||||
expect(collection.updateOne).toHaveBeenCalledWith(
|
||||
{ _id: 'cmd-1' },
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({ status: CommandStatus.PENDING }),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── getDevices / getDeviceByIdentifier / deleteByIdentifier ───────────────────
|
||||
|
||||
describe('DeviceService — CRUD & pagination', () => {
|
||||
let service: DeviceService
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DeviceService()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('getDevices retourne data, nbDocuments et pager', async () => {
|
||||
const devices = [makeDevice(), makeDevice({ identifier: 'device-2' })]
|
||||
vi.mocked(DeviceModel.paginateDevices).mockResolvedValue({ data: devices, total: 50 })
|
||||
|
||||
const result = await service.getDevices(0, 25)
|
||||
|
||||
expect(DeviceModel.paginateDevices).toHaveBeenCalledWith(0, 25)
|
||||
expect(result.data).toEqual(devices)
|
||||
expect(result.nbDocuments).toBe(50)
|
||||
expect(result.pager).toBeDefined()
|
||||
})
|
||||
|
||||
it('getDevices utilise les valeurs par défaut offset=0, limit=100', async () => {
|
||||
vi.mocked(DeviceModel.paginateDevices).mockResolvedValue({ data: [], total: 0 })
|
||||
|
||||
await service.getDevices()
|
||||
|
||||
expect(DeviceModel.paginateDevices).toHaveBeenCalledWith(0, 100)
|
||||
})
|
||||
|
||||
it('getDeviceByIdentifier délègue à DeviceModel.findByIdentifier', async () => {
|
||||
const device = makeDevice()
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
||||
|
||||
const result = await service.getDeviceByIdentifier('some-id')
|
||||
|
||||
expect(DeviceModel.findByIdentifier).toHaveBeenCalledWith('some-id')
|
||||
expect(result).toEqual(device)
|
||||
})
|
||||
|
||||
it('getDeviceByIdentifier retourne null si le device est introuvable', async () => {
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(null)
|
||||
|
||||
const result = await service.getDeviceByIdentifier('unknown-id')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('deleteByIdentifier délègue à DeviceModel.deleteOne', async () => {
|
||||
vi.mocked(DeviceModel.deleteOne).mockResolvedValue(true)
|
||||
|
||||
const result = await service.deleteByIdentifier('some-id')
|
||||
|
||||
expect(DeviceModel.deleteOne).toHaveBeenCalledWith({ identifier: 'some-id' })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("deleteByIdentifier retourne false si rien n'a été supprimé", async () => {
|
||||
vi.mocked(DeviceModel.deleteOne).mockResolvedValue(false)
|
||||
|
||||
const result = await service.deleteByIdentifier('unknown-id')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeviceService.calculatePagination', () => {
|
||||
let service: DeviceService
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DeviceService()
|
||||
})
|
||||
|
||||
it('offset=0 avec total >= limit: pas de "previous", "last" et "next" présents', () => {
|
||||
const pager = service.calculatePagination(0, 10, 50)
|
||||
|
||||
expect(pager.first).toEqual({ offset: 0, limit: 10 })
|
||||
expect(pager.previous).toBeUndefined()
|
||||
expect(pager.next).toEqual({ offset: 10, limit: 10 })
|
||||
expect(pager.last).toEqual({ offset: 40, limit: 10 })
|
||||
})
|
||||
|
||||
it('offset > 0: "previous" est calculé', () => {
|
||||
const pager = service.calculatePagination(20, 10, 50)
|
||||
|
||||
expect(pager.previous).toEqual({ offset: 10, limit: 10 })
|
||||
})
|
||||
|
||||
it('previous ne descend jamais sous 0 (Math.max)', () => {
|
||||
const pager = service.calculatePagination(5, 10, 50)
|
||||
|
||||
expect(pager.previous).toEqual({ offset: 0, limit: 10 })
|
||||
})
|
||||
|
||||
it('dernière page: pas de "next" quand offset + limit >= total', () => {
|
||||
const pager = service.calculatePagination(40, 10, 50)
|
||||
|
||||
expect(pager.next).toBeUndefined()
|
||||
})
|
||||
|
||||
it('total < limit: "last" pointe sur offset 0 (une seule page)', () => {
|
||||
const pager = service.calculatePagination(0, 100, 5)
|
||||
|
||||
expect(pager.last).toEqual({ offset: 0, limit: 100 })
|
||||
expect(pager.next).toBeUndefined()
|
||||
})
|
||||
|
||||
it('total = 0: aucune page suivante, last à offset 0', () => {
|
||||
const pager = service.calculatePagination(0, 10, 0)
|
||||
|
||||
expect(pager.last).toEqual({ offset: 0, limit: 10 })
|
||||
expect(pager.previous).toBeUndefined()
|
||||
expect(pager.next).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeviceService.executeCommand', () => {
|
||||
let service: DeviceService
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DeviceService()
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('lève une erreur si le device est introuvable', async () => {
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
service.executeCommand('unknown-id', { action: CommandType.REBOOT })
|
||||
).rejects.toThrow('Device not found: unknown-id')
|
||||
})
|
||||
|
||||
it('crée une commande en mode NI par défaut (pas de Connection Request)', async () => {
|
||||
const device = makeDevice()
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
||||
const collection = {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
||||
updateOne: vi.fn(),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
|
||||
await service.executeCommand(device.identifier, { action: CommandType.REBOOT })
|
||||
|
||||
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: device.identifier,
|
||||
action: CommandType.REBOOT,
|
||||
mode: ModeType.NI,
|
||||
status: CommandStatus.PENDING,
|
||||
priority: Priority.NORMAL,
|
||||
})
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mode "I" (insensible à la casse) déclenche une Connection Request', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [
|
||||
{ name: 'Device.ManagementServer.ConnectionRequestURL', value: 'http://1.2.3.4:7547/' },
|
||||
],
|
||||
})
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
||||
const collection = {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
||||
updateOne: vi.fn(),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
||||
|
||||
await service.executeCommand(device.identifier, { action: CommandType.REBOOT, mode: 'i' })
|
||||
|
||||
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: ModeType.I })
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lève une erreur si une commande identique est déjà PENDING (duplicate)', async () => {
|
||||
const device = makeDevice()
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
||||
const collection = {
|
||||
findOne: vi.fn().mockResolvedValue({ _id: 'existing-cmd' }),
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
||||
updateOne: vi.fn(),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
|
||||
await expect(
|
||||
service.executeCommand(device.identifier, { action: CommandType.REBOOT })
|
||||
).rejects.toThrow(/Duplicate pending command/)
|
||||
|
||||
expect(CommandModel.createCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('utilise la priorité fournie plutôt que la valeur par défaut', async () => {
|
||||
const device = makeDevice()
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
||||
const collection = {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
||||
updateOne: vi.fn(),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
|
||||
await service.executeCommand(device.identifier, {
|
||||
action: CommandType.SET_PARAMETER_VALUES,
|
||||
priority: Priority.HIGH,
|
||||
})
|
||||
|
||||
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ priority: Priority.HIGH })
|
||||
)
|
||||
})
|
||||
|
||||
it('transmet les parameters fournis, ou {} par défaut', async () => {
|
||||
const device = makeDevice()
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
||||
const collection = {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
||||
updateOne: vi.fn(),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
|
||||
await service.executeCommand(device.identifier, {
|
||||
action: CommandType.SET_PARAMETER_VALUES,
|
||||
parameters: { 'Device.WiFi.SSID': 'MyWifi' },
|
||||
})
|
||||
|
||||
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parameters: { 'Device.WiFi.SSID': 'MyWifi' } })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeviceService.refreshDevice', () => {
|
||||
let service: DeviceService
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DeviceService()
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('lève une erreur si le device est introuvable', async () => {
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(null)
|
||||
|
||||
await expect(service.refreshDevice('unknown-id')).rejects.toThrow(
|
||||
'Device not found: unknown-id'
|
||||
)
|
||||
})
|
||||
|
||||
it('crée une commande REFRESH en mode I et déclenche la Connection Request', async () => {
|
||||
const device = makeDevice({
|
||||
properties: [
|
||||
{ name: 'Device.ManagementServer.ConnectionRequestURL', value: 'http://1.2.3.4:7547/' },
|
||||
],
|
||||
})
|
||||
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
||||
const collection = {
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
||||
updateOne: vi.fn(),
|
||||
}
|
||||
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
||||
|
||||
await service.refreshDevice(device.identifier)
|
||||
|
||||
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: CommandType.REFRESH,
|
||||
mode: ModeType.I,
|
||||
status: CommandStatus.PENDING,
|
||||
priority: Priority.NORMAL,
|
||||
})
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ReferenceEngine } from '../../../../app/services/references/reference_engine.js'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { DeviceRecord } from '../../../../app/domain/device/device_record.js'
|
||||
import type { InformContext } from '../../../../app/services/references/types.js'
|
||||
import type { InformContext, ReferencesConfig } from '../../../../app/services/references/types.js'
|
||||
import { DeviceId } from '../../../../app/domain/device/device_id.js'
|
||||
|
||||
// This suite exercises ReferenceEngine's provider-loading and dispatch logic in
|
||||
// isolation from config/references.json (which reflects production settings and
|
||||
// may legitimately have providers disabled, e.g. wassup when its mock is
|
||||
// unreachable — see commit 1fc9715). A mutable test fixture (reset per test)
|
||||
// keeps this suite deterministic regardless of that file's contents, while
|
||||
// still allowing individual tests to exercise other provider types/configs.
|
||||
const defaultTestConfig: ReferencesConfig = {
|
||||
providers: [
|
||||
{
|
||||
name: 'wassup',
|
||||
type: 'wassup',
|
||||
enabled: true,
|
||||
url: 'http://wassup-mock:9999',
|
||||
timeout: 3000,
|
||||
trigger: ['BOOT', 'BOOTSTRAP'],
|
||||
resolveBy: 'ip',
|
||||
options: {
|
||||
service: 'KRMSCO',
|
||||
requestedProperties: ['sai', 'spr', 'clo', 'puid', 'utz'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
let testConfig: ReferencesConfig = defaultTestConfig
|
||||
|
||||
vi.mock('../../../../app/services/references/config_loader.js', () => ({
|
||||
loadReferencesConfig: vi.fn(() => testConfig),
|
||||
reloadReferencesConfig: vi.fn(() => testConfig),
|
||||
}))
|
||||
|
||||
// Import AFTER mocking config_loader so ReferenceEngine picks up the test fixture.
|
||||
const { ReferenceEngine, getReferenceEngine, resetReferenceEngine } =
|
||||
await import('../../../../app/services/references/reference_engine.js')
|
||||
|
||||
beforeEach(() => {
|
||||
testConfig = defaultTestConfig
|
||||
resetReferenceEngine()
|
||||
})
|
||||
|
||||
function makeDevice(): DeviceRecord {
|
||||
return {
|
||||
identifier: 'test-001',
|
||||
@@ -29,7 +68,11 @@ describe('ReferenceEngine (full)', () => {
|
||||
|
||||
it('resolveAll returns empty when no events match triggers', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
const result = await engine.resolveAll(makeDevice(), { events: ['6 CONNECTION REQUEST'], remoteIp: '10.0.0.1', headers: {} })
|
||||
const result = await engine.resolveAll(makeDevice(), {
|
||||
events: ['6 CONNECTION REQUEST'],
|
||||
remoteIp: '10.0.0.1',
|
||||
headers: {},
|
||||
})
|
||||
// CONNECTION_REQUEST is not in the wassup trigger config (only BOOT, BOOTSTRAP)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
@@ -38,7 +81,11 @@ describe('ReferenceEngine (full)', () => {
|
||||
const engine = new ReferenceEngine()
|
||||
// This will attempt to call the real wassup-mock (which may not be running)
|
||||
// We just verify it doesn't crash and returns a result (error or ok)
|
||||
const result = await engine.resolveAll(makeDevice(), { events: ['1 BOOT'], remoteIp: '10.0.0.1', headers: {} })
|
||||
const result = await engine.resolveAll(makeDevice(), {
|
||||
events: ['1 BOOT'],
|
||||
remoteIp: '10.0.0.1',
|
||||
headers: {},
|
||||
})
|
||||
// Either resolved or error (depending on whether mock is up)
|
||||
expect(result.length).toBeLessThanOrEqual(1)
|
||||
if (result.length > 0) {
|
||||
@@ -49,19 +96,31 @@ describe('ReferenceEngine (full)', () => {
|
||||
|
||||
it('resolveAll triggers on numeric event "0" (BOOTSTRAP)', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
const result = await engine.resolveAll(makeDevice(), { events: ['0'], remoteIp: '1.2.3.4', headers: {} })
|
||||
const result = await engine.resolveAll(makeDevice(), {
|
||||
events: ['0'],
|
||||
remoteIp: '1.2.3.4',
|
||||
headers: {},
|
||||
})
|
||||
expect(result.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('resolveOne returns null for unknown provider', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
const result = await engine.resolveOne('nonexistent', makeDevice(), { events: [], remoteIp: '', headers: {} })
|
||||
const result = await engine.resolveOne('nonexistent', makeDevice(), {
|
||||
events: [],
|
||||
remoteIp: '',
|
||||
headers: {},
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('resolveOne calls the named provider', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
const result = await engine.resolveOne('wassup', makeDevice(), { events: [], remoteIp: '10.0.0.1', headers: {} })
|
||||
const result = await engine.resolveOne('wassup', makeDevice(), {
|
||||
events: [],
|
||||
remoteIp: '10.0.0.1',
|
||||
headers: {},
|
||||
})
|
||||
// Will get an error or unknown since mock may not be running, but provider is called
|
||||
if (result) {
|
||||
expect(result.provider).toBe('wassup')
|
||||
@@ -69,3 +128,158 @@ describe('ReferenceEngine (full)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReferenceEngine — provider loading edge cases', () => {
|
||||
it('skips a disabled provider entirely (never registered)', () => {
|
||||
testConfig = {
|
||||
providers: [
|
||||
{
|
||||
name: 'wassup',
|
||||
type: 'wassup',
|
||||
enabled: false,
|
||||
trigger: ['BOOT'],
|
||||
resolveBy: 'ip',
|
||||
},
|
||||
],
|
||||
}
|
||||
const engine = new ReferenceEngine()
|
||||
expect(engine.getProviderNames()).toEqual([])
|
||||
// Config is still tracked even if the provider itself isn't instantiated
|
||||
expect(engine.getProviderConfigs()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not register a provider for unimplemented type "http-json"', () => {
|
||||
testConfig = {
|
||||
providers: [
|
||||
{ name: 'crm', type: 'http-json', enabled: true, trigger: ['BOOT'], resolveBy: 'ip' },
|
||||
],
|
||||
}
|
||||
const engine = new ReferenceEngine()
|
||||
expect(engine.getProviderNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not register a provider for unimplemented type "http-properties"', () => {
|
||||
testConfig = {
|
||||
providers: [
|
||||
{ name: 'noc', type: 'http-properties', enabled: true, trigger: ['BOOT'], resolveBy: 'ip' },
|
||||
],
|
||||
}
|
||||
const engine = new ReferenceEngine()
|
||||
expect(engine.getProviderNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not register a provider for unimplemented type "static"', () => {
|
||||
testConfig = {
|
||||
providers: [
|
||||
{ name: 'fixed', type: 'static', enabled: true, trigger: ['BOOT'], resolveBy: 'ip' },
|
||||
],
|
||||
}
|
||||
const engine = new ReferenceEngine()
|
||||
expect(engine.getProviderNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not register a provider for an unknown/unrecognized type', () => {
|
||||
testConfig = {
|
||||
providers: [
|
||||
{
|
||||
name: 'weird',
|
||||
type: 'unknown-type' as any,
|
||||
enabled: true,
|
||||
trigger: ['BOOT'],
|
||||
resolveBy: 'ip',
|
||||
},
|
||||
],
|
||||
}
|
||||
const engine = new ReferenceEngine()
|
||||
expect(engine.getProviderNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('handles an empty provider list gracefully', () => {
|
||||
testConfig = { providers: [] }
|
||||
const engine = new ReferenceEngine()
|
||||
expect(engine.getProviderNames()).toEqual([])
|
||||
expect(engine.getProviderConfigs()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReferenceEngine.getApplicableProviders — orphan provider (config removed)', () => {
|
||||
it('excludes a provider whose config entry is no longer present', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
// Simulate a stale in-memory provider whose config was removed at runtime
|
||||
;(engine as any).configs = []
|
||||
|
||||
const result = await engine.resolveAll(makeDevice(), {
|
||||
events: ['1 BOOT'],
|
||||
remoteIp: '10.0.0.1',
|
||||
headers: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReferenceEngine.resolveAll — provider rejection handling', () => {
|
||||
it('logs and skips a provider whose resolve() promise rejects', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
const provider = (engine as any).providers[0]
|
||||
vi.spyOn(provider, 'resolve').mockRejectedValue(new Error('boom'))
|
||||
|
||||
const result = await engine.resolveAll(makeDevice(), {
|
||||
events: ['1 BOOT'],
|
||||
remoteIp: '10.0.0.1',
|
||||
headers: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes a fulfilled-but-null resolution from the results', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
const provider = (engine as any).providers[0]
|
||||
vi.spyOn(provider, 'resolve').mockResolvedValue(null)
|
||||
|
||||
const result = await engine.resolveAll(makeDevice(), {
|
||||
events: ['1 BOOT'],
|
||||
remoteIp: '10.0.0.1',
|
||||
headers: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('includes a fulfilled resolution in the results', async () => {
|
||||
const engine = new ReferenceEngine()
|
||||
const provider = (engine as any).providers[0]
|
||||
const resolved = {
|
||||
provider: 'wassup',
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: 'ip:10.0.0.1',
|
||||
status: 'ok' as const,
|
||||
properties: { sai: 'value' },
|
||||
}
|
||||
vi.spyOn(provider, 'resolve').mockResolvedValue(resolved)
|
||||
|
||||
const result = await engine.resolveAll(makeDevice(), {
|
||||
events: ['1 BOOT'],
|
||||
remoteIp: '10.0.0.1',
|
||||
headers: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual([resolved])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReferenceEngine / resetReferenceEngine — singleton lifecycle', () => {
|
||||
it('returns the same instance on repeated calls', () => {
|
||||
const a = getReferenceEngine()
|
||||
const b = getReferenceEngine()
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('resetReferenceEngine forces a new instance to be created next call', () => {
|
||||
const a = getReferenceEngine()
|
||||
resetReferenceEngine()
|
||||
const b = getReferenceEngine()
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user