Files
adam-acs/tests/unit/services/parameter_writability.spec.ts
T
Kevin L'HereecandCopilot b8ff4b68a7 feat: TR-181 parameter writability resolution
- Add GpnStatus type ('supported' | 'unsupported' | 'unknown') to Device domain
- Persist gpnStatus in DeviceRecord and all 3 persist paths
  (cwmp_controller, main_state_machine, device_action_executor)
- Capture Fault 9000/9001 in getParameterNames → mark device as 'unsupported'
- Fix GPN NextLevel flag: false (non-recursive shallow scan)
- Add static TR-181 catalog (7 727 params, sourced from BBF cwmp-data-models)
- Add parameter_writability service with resolveWritability():
  supported → live data; unsupported → catalog → default R/W;
  unknown → catalog → null (genuinely unknown)
- Add scripts/generate-tr181-catalog.mjs to regenerate catalog from BBF XML
- Add design doc: docs/design-parameter-writability.md
- Add unit tests: 26 tests covering normalizePath and all gpnStatus branches

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 11:25:15 +02:00

192 lines
7.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
normalizePath,
resolveWritability,
} from '../../../app/services/parameter_writability.js'
import type { DeviceSnapshot } from '../../../app/domain/device/device.js'
import { OnboardingStatus } from '../../../app/domain/device/device.js'
import { DeviceId } from '../../../app/domain/device/device_id.js'
import { ParameterType } from '../../../app/domain/shared/types.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeSnapshot(
overrides: Partial<DeviceSnapshot> = {}
): DeviceSnapshot {
return {
deviceId: new DeviceId('Sagemcom', '10:6f:3f', 'Fast5360', 'SN123'),
identifier: 'abc123',
createdAt: new Date(),
lastSeen: new Date(),
onboardingStatus: OnboardingStatus.COMPLETED,
isOnline: false,
gpnStatus: 'unknown',
parameters: [],
properties: [],
...overrides,
}
}
function param(name: string) {
return { name, value: null, type: ParameterType.STRING }
}
// ---------------------------------------------------------------------------
// normalizePath
// ---------------------------------------------------------------------------
describe('normalizePath', () => {
it('leaves paths without numeric segments unchanged', () => {
expect(normalizePath('Device.DeviceInfo.Manufacturer')).toBe(
'Device.DeviceInfo.Manufacturer'
)
})
it('replaces a single numeric segment in the middle', () => {
expect(normalizePath('Device.IP.Interface.1.Enable')).toBe(
'Device.IP.Interface.{i}.Enable'
)
})
it('replaces a numeric segment at the end', () => {
expect(normalizePath('Device.IP.Interface.1')).toBe(
'Device.IP.Interface.{i}'
)
})
it('replaces multiple numeric segments', () => {
expect(
normalizePath('Device.IP.Interface.1.IPv4Address.2.IPAddress')
).toBe('Device.IP.Interface.{i}.IPv4Address.{i}.IPAddress')
})
it('handles large instance numbers', () => {
expect(normalizePath('Device.WiFi.AccessPoint.99.Security.WEPKey.10.WEPKey')).toBe(
'Device.WiFi.AccessPoint.{i}.Security.WEPKey.{i}.WEPKey'
)
})
})
// ---------------------------------------------------------------------------
// resolveWritability — gpnStatus = 'supported'
// ---------------------------------------------------------------------------
describe('resolveWritability — gpnStatus: supported', () => {
it('returns writable=true source=live when param is in parameters[]', () => {
const snapshot = makeSnapshot({
gpnStatus: 'supported',
parameters: [param('Device.ManagementServer.URL')],
})
expect(resolveWritability(snapshot, 'Device.ManagementServer.URL')).toEqual(
{ writable: true, source: 'live' }
)
})
it('returns writable=false source=live when param is in properties[]', () => {
const snapshot = makeSnapshot({
gpnStatus: 'supported',
properties: [param('Device.DeviceInfo.Manufacturer')],
})
expect(resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')).toEqual(
{ writable: false, source: 'live' }
)
})
it('falls back to catalog when param is absent from both arrays', () => {
// Device.ManagementServer.URL is readWrite in TR-181 catalog
const snapshot = makeSnapshot({ gpnStatus: 'supported' })
const result = resolveWritability(snapshot, 'Device.ManagementServer.URL')
// catalog or unknown — but NOT live since arrays are empty
expect(result.source).not.toBe('live')
})
it('parameters[] takes precedence over properties[] when both have the param', () => {
const snapshot = makeSnapshot({
gpnStatus: 'supported',
parameters: [param('Device.ManagementServer.URL')],
properties: [param('Device.ManagementServer.URL')],
})
expect(resolveWritability(snapshot, 'Device.ManagementServer.URL')).toEqual(
{ writable: true, source: 'live' }
)
})
})
// ---------------------------------------------------------------------------
// resolveWritability — gpnStatus = 'unsupported'
// ---------------------------------------------------------------------------
describe('resolveWritability — gpnStatus: unsupported', () => {
it('returns catalog value for a known readOnly TR-181 parameter', () => {
// Device.DeviceInfo.Manufacturer is readOnly in TR-181
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
const result = resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')
expect(result).toEqual({ writable: false, source: 'catalog' })
})
it('returns catalog value for a known readWrite TR-181 parameter', () => {
// Device.ManagementServer.URL is readWrite in TR-181
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
const result = resolveWritability(snapshot, 'Device.ManagementServer.URL')
expect(result).toEqual({ writable: true, source: 'catalog' })
})
it('returns catalog value for an instance path (numeric → {i} normalization)', () => {
// Device.IP.Interface.{i}.Enable is readWrite in TR-181
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
const result = resolveWritability(snapshot, 'Device.IP.Interface.1.Enable')
expect(result).toEqual({ writable: true, source: 'catalog' })
})
it('returns writable=true source=default for a path absent from catalog', () => {
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
const result = resolveWritability(snapshot, 'Device.Vendor.CustomParam.NotInCatalog')
expect(result).toEqual({ writable: true, source: 'default' })
})
it('ignores parameters[] and properties[] (does not use live data)', () => {
const snapshot = makeSnapshot({
gpnStatus: 'unsupported',
parameters: [param('Device.ManagementServer.URL')],
properties: [param('Device.DeviceInfo.Manufacturer')],
})
// Should use catalog, not live arrays
const result = resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')
expect(result.source).toBe('catalog')
})
})
// ---------------------------------------------------------------------------
// resolveWritability — gpnStatus = 'unknown'
// ---------------------------------------------------------------------------
describe('resolveWritability — gpnStatus: unknown', () => {
it('returns catalog value for a known readOnly TR-181 parameter', () => {
const snapshot = makeSnapshot({ gpnStatus: 'unknown' })
const result = resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')
expect(result).toEqual({ writable: false, source: 'catalog' })
})
it('returns catalog value for a known readWrite TR-181 parameter', () => {
const snapshot = makeSnapshot({ gpnStatus: 'unknown' })
const result = resolveWritability(snapshot, 'Device.ManagementServer.URL')
expect(result).toEqual({ writable: true, source: 'catalog' })
})
it('returns writable=null source=unknown for a path absent from catalog', () => {
const snapshot = makeSnapshot({ gpnStatus: 'unknown' })
const result = resolveWritability(snapshot, 'Device.Vendor.CustomParam.NotInCatalog')
expect(result).toEqual({ writable: null, source: 'unknown' })
})
it('distinguishes from unsupported: returns null not true for missing params', () => {
const snapshotUnknown = makeSnapshot({ gpnStatus: 'unknown' })
const snapshotUnsupported = makeSnapshot({ gpnStatus: 'unsupported' })
const path = 'Device.Vendor.CustomParam.NotInCatalog'
expect(resolveWritability(snapshotUnknown, path).writable).toBeNull()
expect(resolveWritability(snapshotUnsupported, path).writable).toBe(true)
})
})