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([]) } ) ) }) })