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

260 lines
8.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import fc from 'fast-check'
import { MigrationRoute } from '../../app/domain/firmware/firmware_catalog.js'
/**
* Property 13: Firmware migration path resolution
* Validates: Requirements 7.3
*
* Verifies that:
* 1. If currentVersion === targetVersion, result is always empty array
* 2. If a direct route exists from current to target, path has exactly 1 step
* 3. Path steps are contiguous: step[i].toVersion === step[i+1].fromVersion
* 4. No version appears twice in the path (no cycles)
* 5. First step starts from currentVersion, last step ends at targetVersion (when path exists)
*/
vi.mock('../../app/models/migration_route.js', () => ({
default: {
findByProductClass: vi.fn(),
},
}))
vi.mock('../../app/models/firmware_entry.js', () => ({
default: {
findByProductClassAndVersion: vi.fn(),
},
}))
import MigrationRouteModel from '../../app/models/migration_route.js'
import FirmwareEntryModel from '../../app/models/firmware_entry.js'
import FirmwareCatalogService from '../../app/services/firmware_catalog_service.js'
describe('Property 13: Firmware migration path resolution', () => {
let service: FirmwareCatalogService
beforeEach(() => {
service = new FirmwareCatalogService()
vi.mocked(MigrationRouteModel.findByProductClass).mockReset()
vi.mocked(FirmwareEntryModel.findByProductClassAndVersion).mockReset()
vi.mocked(FirmwareEntryModel.findByProductClassAndVersion).mockResolvedValue(null)
})
/**
* Arbitrary for version strings (e.g., "1.0.0", "2.3.1")
*/
const versionArb = fc
.tuple(
fc.integer({ min: 0, max: 99 }),
fc.integer({ min: 0, max: 99 }),
fc.integer({ min: 0, max: 99 })
)
.map(([major, minor, patch]) => `${major}.${minor}.${patch}`)
/**
* Arbitrary for productClass strings
*/
const productClassArb = fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{2,10}$/)
/**
* Helper to create a MigrationRoute object
*/
function makeRoute(
productClass: string,
sourceVersionPattern: string,
targetVersion: string,
priority: number = 10
): MigrationRoute {
return {
identifier: `${productClass}-${sourceVersionPattern}-to-${targetVersion}`,
productClass,
sourceVersionPattern,
targetVersion,
downloadUrl: `http://firmware.local/${productClass}/${targetVersion}.bin`,
priority,
createdAt: new Date(),
}
}
/**
* Helper to set up mocks for a given set of routes
*/
function setupMocks(routes: MigrationRoute[]) {
vi.mocked(MigrationRouteModel.findByProductClass).mockImplementation(
async (pc: string) => routes.filter((r) => r.productClass === pc)
)
}
it('1. If currentVersion === targetVersion, result is always empty array', async () => {
await fc.assert(
fc.asyncProperty(productClassArb, versionArb, async (productClass, version) => {
setupMocks([])
const result = await service.resolveUpgradePath(productClass, version, version)
expect(result).toEqual([])
})
)
})
it('2. If a direct route exists from current to target, path has exactly 1 step', async () => {
await fc.assert(
fc.asyncProperty(
productClassArb,
versionArb,
versionArb,
fc.integer({ min: 1, max: 100 }),
async (productClass, currentVersion, targetVersion, priority) => {
fc.pre(currentVersion !== targetVersion)
const route = makeRoute(productClass, currentVersion, targetVersion, priority)
setupMocks([route])
const result = await service.resolveUpgradePath(
productClass,
currentVersion,
targetVersion
)
expect(result).toHaveLength(1)
expect(result[0].fromVersion).toBe(currentVersion)
expect(result[0].toVersion).toBe(targetVersion)
}
)
)
})
it('3. Path steps are contiguous: step[i].toVersion === step[i+1].fromVersion', async () => {
/**
* Generate a chain of distinct versions and create routes forming a linear path.
*/
const distinctVersionsArb = fc
.array(versionArb, { minLength: 3, maxLength: 6 })
.map((versions) => [...new Set(versions)])
.filter((unique) => unique.length >= 3)
await fc.assert(
fc.asyncProperty(productClassArb, distinctVersionsArb, async (productClass, versionList) => {
// Create a chain of routes: v0→v1, v1→v2, ..., v(n-2)→v(n-1)
const routes: MigrationRoute[] = []
for (let i = 0; i < versionList.length - 1; i++) {
routes.push(makeRoute(productClass, versionList[i], versionList[i + 1], 10))
}
setupMocks(routes)
const currentVersion = versionList[0]
const targetVersion = versionList[versionList.length - 1]
const result = await service.resolveUpgradePath(
productClass,
currentVersion,
targetVersion
)
// Path should be found and contiguous
expect(result.length).toBeGreaterThan(0)
for (let i = 0; i < result.length - 1; i++) {
expect(result[i].toVersion).toBe(result[i + 1].fromVersion)
}
})
)
})
it('4. No version appears twice in the path (no cycles)', async () => {
const distinctVersionsArb = fc
.array(versionArb, { minLength: 2, maxLength: 6 })
.map((versions) => [...new Set(versions)])
.filter((unique) => unique.length >= 2)
await fc.assert(
fc.asyncProperty(productClassArb, distinctVersionsArb, async (productClass, versionList) => {
// Create routes forming a chain
const routes: MigrationRoute[] = []
for (let i = 0; i < versionList.length - 1; i++) {
routes.push(makeRoute(productClass, versionList[i], versionList[i + 1], 10))
}
setupMocks(routes)
const currentVersion = versionList[0]
const targetVersion = versionList[versionList.length - 1]
const result = await service.resolveUpgradePath(
productClass,
currentVersion,
targetVersion
)
// Collect all versions in the path
if (result.length > 0) {
const allVersions: string[] = [result[0].fromVersion]
for (const step of result) {
allVersions.push(step.toVersion)
}
// No duplicates
const uniqueVersions = new Set(allVersions)
expect(uniqueVersions.size).toBe(allVersions.length)
}
})
)
})
it('5. First step starts from currentVersion, last step ends at targetVersion (when path exists)', async () => {
const distinctVersionsArb = fc
.array(versionArb, { minLength: 2, maxLength: 6 })
.map((versions) => [...new Set(versions)])
.filter((unique) => unique.length >= 2)
await fc.assert(
fc.asyncProperty(productClassArb, distinctVersionsArb, async (productClass, versionList) => {
// Create routes forming a chain
const routes: MigrationRoute[] = []
for (let i = 0; i < versionList.length - 1; i++) {
routes.push(makeRoute(productClass, versionList[i], versionList[i + 1], 10))
}
setupMocks(routes)
const currentVersion = versionList[0]
const targetVersion = versionList[versionList.length - 1]
const result = await service.resolveUpgradePath(
productClass,
currentVersion,
targetVersion
)
if (result.length > 0) {
expect(result[0].fromVersion).toBe(currentVersion)
expect(result[result.length - 1].toVersion).toBe(targetVersion)
}
})
)
})
it('cycle detection: routes forming a cycle return empty path', async () => {
await fc.assert(
fc.asyncProperty(
productClassArb,
versionArb,
versionArb,
versionArb,
async (productClass, v1, v2, target) => {
// Ensure all three are distinct
fc.pre(v1 !== v2 && v2 !== target && v1 !== target)
// Create a cycle: v1→v2, v2→v1 (cycle), no route to target
const routes: MigrationRoute[] = [
makeRoute(productClass, v1, v2, 10),
makeRoute(productClass, v2, v1, 10),
]
setupMocks(routes)
const result = await service.resolveUpgradePath(productClass, v1, target)
// Should return empty (either no path to target, or cycle detected)
expect(result).toEqual([])
}
)
)
})
})