- 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>
192 lines
7.6 KiB
TypeScript
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)
|
|
})
|
|
})
|