From 76e75132d59788dc4249c1f6b0ff7f2df9e5db2b Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Wed, 22 Jul 2026 11:45:50 +0200 Subject: [PATCH] test: raise business-logic unit test coverage across core services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...iguration_version_history.property.spec.ts | 43 +- .../domain/device_gpn_circuit_breaker.spec.ts | 306 ++++++++++ .../services/configuration_engine.spec.ts | 565 ++++++++++++++++-- .../services/cwmp/main_state_machine.spec.ts | 481 ++++++++++++++- .../cwmp/state_machine_manager.spec.ts | 354 ++++++++++- tests/unit/services/device_service.spec.ts | 483 ++++++++++++++- .../references/reference_engine_full.spec.ts | 230 ++++++- 7 files changed, 2326 insertions(+), 136 deletions(-) create mode 100644 tests/unit/domain/device_gpn_circuit_breaker.spec.ts diff --git a/tests/property/configuration_version_history.property.spec.ts b/tests/property/configuration_version_history.property.spec.ts index 67bf876..aaf0867 100644 --- a/tests/property/configuration_version_history.property.spec.ts +++ b/tests/property/configuration_version_history.property.spec.ts @@ -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) => { 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)), + }) + }), }, })) diff --git a/tests/unit/domain/device_gpn_circuit_breaker.spec.ts b/tests/unit/domain/device_gpn_circuit_breaker.spec.ts new file mode 100644 index 0000000..75ef23c --- /dev/null +++ b/tests/unit/domain/device_gpn_circuit_breaker.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/services/configuration_engine.spec.ts b/tests/unit/services/configuration_engine.spec.ts index b047525..17e1b99 100644 --- a/tests/unit/services/configuration_engine.spec.ts +++ b/tests/unit/services/configuration_engine.spec.ts @@ -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) + }) + }) }) diff --git a/tests/unit/services/cwmp/main_state_machine.spec.ts b/tests/unit/services/cwmp/main_state_machine.spec.ts index 4bcf94b..b9fa1c9 100644 --- a/tests/unit/services/cwmp/main_state_machine.spec.ts +++ b/tests/unit/services/cwmp/main_state_machine.spec.ts @@ -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 { + 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 { + 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') + }) }) - - diff --git a/tests/unit/services/cwmp/state_machine_manager.spec.ts b/tests/unit/services/cwmp/state_machine_manager.spec.ts index a98148a..cab26d8 100644 --- a/tests/unit/services/cwmp/state_machine_manager.spec.ts +++ b/tests/unit/services/cwmp/state_machine_manager.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/services/device_service.spec.ts b/tests/unit/services/device_service.spec.ts index 84df3c0..b1f5453 100644 --- a/tests/unit/services/device_service.spec.ts +++ b/tests/unit/services/device_service.spec.ts @@ -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 { 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 + + 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 + + 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() + }) }) diff --git a/tests/unit/services/references/reference_engine_full.spec.ts b/tests/unit/services/references/reference_engine_full.spec.ts index 71ec97c..8447f3b 100644 --- a/tests/unit/services/references/reference_engine_full.spec.ts +++ b/tests/unit/services/references/reference_engine_full.spec.ts @@ -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) + }) +})