Files
adam-acs/tests/property/migration_service.property.spec.ts
T
Kevin L'HereecandCopilot d1c65dbf1e chore: initial commit — AdonisJS v7 TR-069 ACS
- Docker infrastructure (Dockerfile, docker-compose.yml, genieacs-sim)
- AdonisJS v7 app with Inertia.js + Vue 3
- MongoDB integration
- CWMP/TR-069 state machine engine
- Configuration engine with compliance tracking
- Branding: ADAM ACS (favicon, navbar, locales)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 16:46:37 +02:00

432 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import fc from 'fast-check'
import MigrationService from '../../app/services/migration_service.js'
import type { CreateConfigurationDto } from '../../app/services/configuration_service.js'
// Mock services
vi.mock('../../app/services/configuration_service.js', () => {
return {
default: class ConfigurationService {
private configurations: Map<string, any> = new Map()
async findById(id: string) {
return this.configurations.get(id) || null
}
async findAll() {
return {
data: Array.from(this.configurations.values()),
}
}
async create(dto: CreateConfigurationDto) {
this.configurations.set(dto.identifier, {
...dto,
id: dto.identifier,
createdAt: new Date(),
updatedAt: new Date(),
})
return this.configurations.get(dto.identifier)
}
},
}
})
vi.mock('../../app/services/firmware_catalog_service.js', () => {
return {
default: class FirmwareCatalogService {
async createEntry() {
return {}
}
},
}
})
/**
* Property 14: Migration preserves semantics
*
* For any legacy Discovery configuration with modelPaths M and criteria C,
* the migration SHALL produce a Configuration with inventory paths derived
* from M, criteria derived from C's DEVICE_ID entries, policy "maintain",
* and empty desiredParameters. For any legacy Provisioning configuration
* with SET_PARAMETER_VALUES actions, the migration SHALL produce a
* Configuration with desiredParameters derived from those actions and
* policy "once". In both cases, the original identifier (prefixed) and
* creationDate SHALL be preserved.
*
* Validates: Requirements 14.1, 14.2, 14.3, 14.4
*/
describe('Property 14: Migration preserves semantics', () => {
let mockDb: any
beforeEach(() => {
mockDb = {
collection: vi.fn((_name: string) => {
return {
find: vi.fn(() => ({
toArray: vi.fn(async () => []),
})),
insertOne: vi.fn(async () => ({})),
}
}),
}
new MigrationService(mockDb)
})
/**
* Property 14.1: Discovery migration preserves semantics
*
* For any legacy Discovery config with:
* - identifier
* - modelPaths (array of strings)
* - criteria (manufacturer, oui, productClass, serialNumber)
* - creationDate
*
* The migrated Configuration SHALL have:
* - identifier prefixed with "legacy-disc-"
* - inventory paths derived from modelPaths
* - criteria matching the legacy criteria
* - policy "maintain"
* - empty desiredParameters
*/
it('Property 14.1: Discovery migration preserves inventory paths, criteria, and policy', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
identifier: fc.string({ minLength: 1 }),
name: fc.string(),
modelPaths: fc.array(fc.string({ minLength: 1 }), { minLength: 1 }),
manufacturer: fc.option(fc.string()),
oui: fc.option(fc.string()),
productClass: fc.option(fc.string()),
serialNumber: fc.option(fc.string()),
createdAt: fc.date(),
}),
async (legacyConfig) => {
// Create a mock discovery config
const mockCollection = {
find: vi.fn(() => ({
toArray: vi.fn(async () => [legacyConfig]),
})),
}
const mockDb = {
collection: vi.fn((name: string) => {
if (name === 'discovery_configs') return mockCollection
return {
find: vi.fn(() => ({
toArray: vi.fn(async () => []),
})),
}
}),
}
// Create a custom config service that captures created configs
const createdConfigs: CreateConfigurationDto[] = []
const mockConfigService = {
async findById() {
return null
},
async create(dto: CreateConfigurationDto) {
createdConfigs.push(dto)
return { ...dto, id: dto.identifier }
},
async findAll() {
return { data: [] }
},
}
const service = new MigrationService(mockDb as any, mockConfigService as any)
const report = await service.migrateAll()
// Should have migrated exactly one discovery config
expect(report.discoveryMigrated).toBe(1)
expect(report.discoverySkipped).toBe(0)
expect(createdConfigs).toHaveLength(1)
const migrated = createdConfigs[0]
// Verify identifier is prefixed
expect(migrated.identifier).toBe(`legacy-disc-${legacyConfig.identifier}`)
// Verify inventory paths are derived from modelPaths
expect(migrated.inventory).toHaveLength(legacyConfig.modelPaths.length)
for (let i = 0; i < legacyConfig.modelPaths.length; i++) {
expect(migrated.inventory[i]).toEqual({ path: legacyConfig.modelPaths[i] })
}
// Verify criteria match legacy values (only truthy values)
;(['manufacturer', 'oui', 'productClass', 'serialNumber'] as const).forEach((key) => {
const legacyValue = (legacyConfig as any)[key]
if (legacyValue) {
expect(migrated.criteria[key]).toBe(legacyValue)
} else {
expect(migrated.criteria[key]).toBeUndefined()
}
})
expect(migrated.policy).toBe('maintain')
// Verify desiredParameters is empty
expect(migrated.desiredParameters).toEqual([])
// Verify tags include "legacy" and "discovery"
expect(migrated.tags).toContain('legacy')
expect(migrated.tags).toContain('discovery')
}
),
{ numRuns: 50 }
)
})
/**
* Property 14.2: Provisioning migration preserves semantics
*
* For any legacy Provisioning config with:
* - identifier
* - actions (array of SET_PARAMETER actions)
* - criteria (manufacturer, oui, productClass, serialNumber)
* - downloadActions (optional, determines policy)
* - creationDate
*
* The migrated Configuration SHALL have:
* - identifier prefixed with "legacy-prov-"
* - desiredParameters derived from SET_PARAMETER actions
* - policy "once" if downloadActions exist, "maintain" otherwise
* - criteria matching the legacy criteria
* - tags including "legacy" and "provisioning"
*/
it('Property 14.2: Provisioning migration preserves parameters, policy, and criteria', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
identifier: fc.string({ minLength: 1 }),
name: fc.string(),
manufacturer: fc.option(fc.string()),
oui: fc.option(fc.string()),
productClass: fc.option(fc.string()),
serialNumber: fc.option(fc.string()),
actions: fc.array(
fc.record({
type: fc.constant('SET_PARAMETER'),
parameter: fc.string({ minLength: 1 }),
value: fc.oneof(fc.string(), fc.integer(), fc.boolean()),
}),
{ minLength: 1 }
),
downloadActions: fc.array(fc.record({ version: fc.string() }), {
minLength: 0,
maxLength: 2,
}),
createdAt: fc.date(),
}),
async (legacyConfig) => {
// Create a mock provisioning config
const mockCollection = {
find: vi.fn(() => ({
toArray: vi.fn(async () => [legacyConfig]),
})),
}
const mockDb = {
collection: vi.fn((name: string) => {
if (name === 'provisioning_configs') return mockCollection
return {
find: vi.fn(() => ({
toArray: vi.fn(async () => []),
})),
}
}),
}
// Create a custom config service that captures created configs
const createdConfigs: CreateConfigurationDto[] = []
const mockConfigService = {
async findById() {
return null
},
async create(dto: CreateConfigurationDto) {
createdConfigs.push(dto)
return { ...dto, id: dto.identifier }
},
async findAll() {
return { data: [] }
},
}
const service = new MigrationService(mockDb as any, mockConfigService as any)
const report = await service.migrateAll()
// Should have migrated exactly one provisioning config
expect(report.provisioningMigrated).toBe(1)
expect(report.provisioningSkipped).toBe(0)
expect(createdConfigs).toHaveLength(1)
const migrated = createdConfigs[0]
// Verify identifier is prefixed
expect(migrated.identifier).toBe(`legacy-prov-${legacyConfig.identifier}`)
// Verify desiredParameters are derived from SET_PARAMETER actions
expect(migrated.desiredParameters).toHaveLength(legacyConfig.actions.length)
for (let i = 0; i < legacyConfig.actions.length; i++) {
const param = migrated.desiredParameters[i]
const action = legacyConfig.actions[i]
expect(param.name).toBe(action.parameter)
expect(param.value).toBe(action.value)
}
// Verify policy based on downloadActions
const expectedPolicy = legacyConfig.downloadActions.length > 0 ? 'once' : 'maintain'
expect(migrated.policy).toBe(expectedPolicy)
// Verify criteria match legacy values (only truthy values)
;(['manufacturer', 'oui', 'productClass', 'serialNumber'] as const).forEach((key) => {
const legacyValue = (legacyConfig as any)[key]
if (legacyValue) {
expect(migrated.criteria[key]).toBe(legacyValue)
} else {
expect(migrated.criteria[key]).toBeUndefined()
}
})
expect(migrated.tags).toContain('legacy')
expect(migrated.tags).toContain('provisioning')
}
),
{ numRuns: 50 }
)
})
/**
* Property 14.3: Migration preserves identifier uniqueness
*
* If two different legacy configs have the same identifier, the second
* migration should be skipped (not overwrite the first).
*/
it('Property 14.3: Migration skips duplicate identifiers', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
identifier: fc.string({ minLength: 1 }),
modelPaths: fc.array(fc.string({ minLength: 1 }), { minLength: 1 }),
}),
async (legacyConfig) => {
// Create a config service that returns the first config as already existing
const mockDb = {
collection: vi.fn((name: string) => ({
find: vi.fn(() => ({
toArray: vi.fn(async () => {
if (name === 'discovery_configs') {
// Return the same config twice to test duplicate handling
return [legacyConfig, legacyConfig]
}
return []
}),
})),
})),
}
const createdConfigs: CreateConfigurationDto[] = []
let callCount = 0
const mockConfigService = {
async findById(id: string) {
// First call returns null (not exists), second call returns the config
if (callCount > 0) {
return { identifier: id }
}
return null
},
async create(dto: CreateConfigurationDto) {
callCount++
createdConfigs.push(dto)
return { ...dto, id: dto.identifier }
},
async findAll() {
return { data: [] }
},
}
const service = new MigrationService(mockDb as any, mockConfigService as any)
const report = await service.migrateAll()
// First config created, second skipped
expect(report.discoveryMigrated).toBe(1)
expect(report.discoverySkipped).toBe(1)
expect(createdConfigs).toHaveLength(1)
}
),
{ numRuns: 30 }
)
})
/**
* Property 14.4: Criteria coverage
*
* When legacy config has partial criteria (e.g., only manufacturer),
* the migrated Configuration should preserve exactly those fields
* (not add missing fields with defaults).
*/
it('Property 14.4: Partial criteria preserved exactly', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
identifier: fc.string({ minLength: 1 }),
modelPaths: fc.array(fc.string({ minLength: 1 }), { minLength: 1 }),
manufacturer: fc.option(fc.string()),
oui: fc.option(fc.string()),
productClass: fc.option(fc.string()),
serialNumber: fc.option(fc.string()),
}),
async (legacyConfig) => {
const mockDb = {
collection: vi.fn((name: string) => ({
find: vi.fn(() => ({
toArray: vi.fn(async () => {
if (name === 'discovery_configs') return [legacyConfig]
return []
}),
})),
})),
}
const createdConfigs: CreateConfigurationDto[] = []
const mockConfigService = {
async findById() {
return null
},
async create(dto: CreateConfigurationDto) {
createdConfigs.push(dto)
return { ...dto, id: dto.identifier }
},
async findAll() {
return { data: [] }
},
}
const service = new MigrationService(mockDb as any, mockConfigService as any)
await service.migrateAll()
const migrated = createdConfigs[0]
// Verify criteria only contain truthy values from legacy
Object.entries(migrated.criteria).forEach(([key]) => {
// Each criterion in migrated must come from a truthy value in legacy
expect((legacyConfig as any)[key]).toBeTruthy()
})
// Verify all truthy legacy criteria are in migrated
;(['manufacturer', 'oui', 'productClass', 'serialNumber'] as const).forEach((key) => {
const legacyValue = (legacyConfig as any)[key]
if (legacyValue) {
// Truthy values must be in criteria
expect(migrated.criteria[key]).toBe(legacyValue)
} else {
// Falsy values must NOT be in criteria
expect(migrated.criteria[key]).toBeUndefined()
}
})
}
),
{ numRuns: 50 }
)
})
})