test: raise business-logic unit test coverage across core services
Build & Deploy / build (push) Successful in 34s
Build & Deploy / docker (push) Successful in 1m19s
Build & Deploy / deploy (push) Successful in 37s

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:
Kevin L'Hereec committed 2026-07-22 11:45:50 +02:00
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')
})
})
+520 -45
View File
@@ -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')
})
})
+469 -14
View File
@@ -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)
})
})