Files
adam-acs/tests/property/configuration_version_history.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

298 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as fc from 'fast-check'
import { PolicyType } from '../../app/domain/configuration/configuration.js'
import type {
Configuration,
ConfigurationCriteria,
DesiredParameter,
InventoryPath,
} from '../../app/domain/configuration/configuration.js'
import type { ConfigurationVersion } from '../../app/domain/configuration/configuration_version.js'
/**
* **Validates: Requirements 15.1, 15.2, 15.5**
*
* Property 15: Version history invariants
*
* Properties verified:
* 1. Each update creates exactly one new version snapshot
* 2. Version numbers are strictly increasing (monotonic)
* 3. The snapshot contains the state BEFORE the modification
* 4. Deleting a configuration removes all its versions
*/
// --- Mutable container that won't be affected by hoisting ---
const store = {
configurations: new Map<string, Configuration>(),
versions: [] as ConfigurationVersion[],
}
// --- Mock setup ---
vi.mock('../../app/models/configuration.js', () => ({
default: {
findByIdentifier: vi.fn((identifier: string) => {
const found = store.configurations.get(identifier)
return Promise.resolve(found ? { ...found } : null)
}),
findAllEnabled: vi.fn(() => {
return Promise.resolve(
[...store.configurations.values()].filter((c) => c.enabled)
)
}),
createIfNotExists: vi.fn((data: Partial<Configuration>) => {
if (store.configurations.has(data.identifier!)) {
return Promise.reject(
new Error(`Configuration with identifier '${data.identifier}' already exists`)
)
}
const now = new Date()
const config = { ...data, createdAt: now, updatedAt: now } as Configuration
store.configurations.set(data.identifier!, config)
return Promise.resolve(config)
}),
updateOne: vi.fn((filter: { identifier: string }, update: Partial<Configuration>) => {
const existing = store.configurations.get(filter.identifier)
if (existing) {
const updated = { ...existing, ...update, updatedAt: new Date() }
store.configurations.set(filter.identifier, updated)
}
return Promise.resolve()
}),
deleteByIdentifier: vi.fn((identifier: string) => {
return Promise.resolve(store.configurations.delete(identifier))
}),
},
}))
vi.mock('../../app/models/configuration_version.js', () => ({
default: {
create: vi.fn((data: Omit<ConfigurationVersion, '_id'>) => {
const version = { ...data } as ConfigurationVersion
store.versions.push(version)
return Promise.resolve(version)
}),
getLatestVersionNumber: vi.fn((configurationIdentifier: string) => {
const versions = store.versions.filter(
(v) => v.configurationIdentifier === configurationIdentifier
)
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(
(v) => v.configurationIdentifier !== configurationIdentifier
)
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)),
})
}
),
},
}))
// Import the service AFTER mocking
import ConfigurationService from '../../app/services/configuration_service.js'
// --- Arbitraries ---
/**
* Generates a valid Configuration with inventory paths that cover the desired parameters.
* Configurations are disabled to avoid overlap detection triggering on findAllEnabled.
*/
function validConfigurationArb(): fc.Arbitrary<Configuration> {
return fc
.record({
identifier: fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{2,10}$/),
name: fc.string({ minLength: 1, maxLength: 30 }),
version: fc.stringMatching(/^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}$/),
priority: fc.integer({ min: 1, max: 1000 }),
policy: fc.constantFrom(PolicyType.ONCE, PolicyType.MAINTAIN),
enabled: fc.constant(false), // disabled to avoid overlap detection issues
tags: fc.array(fc.string({ minLength: 1, maxLength: 10 }), { maxLength: 3 }),
criteria: fc.constant({} as ConfigurationCriteria),
createdAt: fc.constant(new Date('2024-01-01')),
updatedAt: fc.constant(new Date('2024-01-01')),
})
.map((base) => ({
...base,
inventory: [{ path: 'Device.Config.' }] as InventoryPath[],
desiredParameters: [] as DesiredParameter[],
firmwareTarget: undefined,
schedule: undefined,
})) as fc.Arbitrary<Configuration>
}
function resetStore() {
store.configurations = new Map()
store.versions = []
}
describe('Property 15: Version history invariants', () => {
let service: ConfigurationService
beforeEach(() => {
resetStore()
service = new ConfigurationService()
})
it('each update creates exactly one new version snapshot', async () => {
await fc.assert(
fc.asyncProperty(validConfigurationArb(), async (config) => {
// Reset stores for each property run
resetStore()
// Seed the configuration in the store
store.configurations.set(config.identifier, { ...config })
const versionCountBefore = store.versions.length
// Perform update
await service.update(config.identifier, { name: 'updated-name' })
const versionCountAfter = store.versions.length
// Exactly one version snapshot should have been created
expect(versionCountAfter - versionCountBefore).toBe(1)
}),
{ numRuns: 100 }
)
})
it('version numbers are strictly increasing (monotonic) across multiple updates', async () => {
await fc.assert(
fc.asyncProperty(
validConfigurationArb(),
fc.integer({ min: 2, max: 5 }),
async (config, updateCount) => {
// Reset stores
resetStore()
// Seed the configuration
store.configurations.set(config.identifier, { ...config })
// Perform multiple updates
for (let i = 0; i < updateCount; i++) {
await service.update(config.identifier, {
name: `${config.name}-v${i + 1}`,
})
}
// Get all versions for this configuration
const versions = store.versions
.filter((v) => v.configurationIdentifier === config.identifier)
.sort((a, b) => a.versionNumber - b.versionNumber)
// Verify strict monotonic increase
expect(versions.length).toBe(updateCount)
for (let i = 1; i < versions.length; i++) {
expect(versions[i].versionNumber).toBeGreaterThan(versions[i - 1].versionNumber)
}
// Verify version numbers start at 1 and increment by 1
for (let i = 0; i < versions.length; i++) {
expect(versions[i].versionNumber).toBe(i + 1)
}
}
),
{ numRuns: 50 }
)
})
it('the snapshot contains the state BEFORE the modification', async () => {
await fc.assert(
fc.asyncProperty(
validConfigurationArb(),
fc.string({ minLength: 1, maxLength: 20 }),
async (config, newName) => {
// Reset stores
resetStore()
// Seed the configuration with a known original name
const originalName = config.name
store.configurations.set(config.identifier, { ...config })
// Perform update with a new name
await service.update(config.identifier, { name: newName })
// The snapshot should contain the ORIGINAL name (state before modification)
const createdVersion = store.versions.find(
(v) => v.configurationIdentifier === config.identifier
)
expect(createdVersion).toBeDefined()
expect(createdVersion!.snapshot.name).toBe(originalName)
// The current configuration should have the NEW name
const current = store.configurations.get(config.identifier)
expect(current!.name).toBe(newName)
}
),
{ numRuns: 100 }
)
})
it('deleting a configuration removes all its versions', async () => {
await fc.assert(
fc.asyncProperty(
validConfigurationArb(),
fc.integer({ min: 1, max: 5 }),
async (config, updateCount) => {
// Reset stores
resetStore()
// Seed the configuration
store.configurations.set(config.identifier, { ...config })
// Perform multiple updates to create versions
for (let i = 0; i < updateCount; i++) {
await service.update(config.identifier, {
name: `${config.name}-v${i + 1}`,
})
}
// Verify versions exist
const versionsBeforeDelete = store.versions.filter(
(v) => v.configurationIdentifier === config.identifier
)
expect(versionsBeforeDelete.length).toBe(updateCount)
// Delete the configuration
await service.delete(config.identifier)
// Verify all versions are removed
const versionsAfterDelete = store.versions.filter(
(v) => v.configurationIdentifier === config.identifier
)
expect(versionsAfterDelete.length).toBe(0)
// Verify the configuration itself is removed
expect(store.configurations.has(config.identifier)).toBe(false)
}
),
{ numRuns: 50 }
)
})
})