import { describe, it, expect, vi, beforeEach } from 'vitest' import FirmwareCatalogService from '../../../app/services/firmware_catalog_service.js' import type { CreateFirmwareEntryDto, CreateMigrationRouteDto } from '../../../app/services/firmware_catalog_service.js' // Mock the models vi.mock('#models/firmware_entry', () => ({ default: { createIfNotExists: vi.fn(), findByProductClass: vi.fn(), findByProductClassAndVersion: vi.fn(), find: vi.fn(), deleteByIdentifier: vi.fn(), }, })) vi.mock('#models/migration_route', () => ({ default: { createIfNotExists: vi.fn(), findByProductClass: vi.fn(), deleteByIdentifier: vi.fn(), }, })) import FirmwareEntryModel from '#models/firmware_entry' import MigrationRouteModel from '#models/migration_route' describe('FirmwareCatalogService', () => { let service: FirmwareCatalogService beforeEach(() => { service = new FirmwareCatalogService() vi.clearAllMocks() }) describe('createEntry', () => { const validEntry: CreateFirmwareEntryDto = { identifier: 'BM632w-fw-2.1.0', productClass: 'BM632w', version: '2.1.0', downloadUrl: 'http://firmware.local/BM632w/2.1.0.bin', checksum: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', checksumType: 'md5', fileSize: 15728640, } it('creates a firmware entry with valid data', async () => { const expected = { ...validEntry, createdAt: new Date() } vi.mocked(FirmwareEntryModel.createIfNotExists).mockResolvedValue(expected as any) const result = await service.createEntry(validEntry) expect(FirmwareEntryModel.createIfNotExists).toHaveBeenCalledWith({ identifier: validEntry.identifier, productClass: validEntry.productClass, version: validEntry.version, downloadUrl: validEntry.downloadUrl, checksum: validEntry.checksum, checksumType: validEntry.checksumType, fileSize: validEntry.fileSize, }) expect(result).toEqual(expected) }) it('rejects invalid downloadUrl', async () => { const invalidEntry = { ...validEntry, downloadUrl: 'not-a-url' } await expect(service.createEntry(invalidEntry)).rejects.toThrow( 'downloadUrl must be a valid URL' ) }) it('rejects non-positive fileSize', async () => { const invalidEntry = { ...validEntry, fileSize: 0 } await expect(service.createEntry(invalidEntry)).rejects.toThrow( 'fileSize must be a positive integer' ) }) it('rejects negative fileSize', async () => { const invalidEntry = { ...validEntry, fileSize: -1 } await expect(service.createEntry(invalidEntry)).rejects.toThrow( 'fileSize must be a positive integer' ) }) it('rejects non-integer fileSize', async () => { const invalidEntry = { ...validEntry, fileSize: 1.5 } await expect(service.createEntry(invalidEntry)).rejects.toThrow( 'fileSize must be a positive integer' ) }) it('rejects invalid checksumType', async () => { const invalidEntry = { ...validEntry, checksumType: 'sha512' as any } await expect(service.createEntry(invalidEntry)).rejects.toThrow( "checksumType must be 'md5' or 'sha256'" ) }) it('rejects empty identifier', async () => { const invalidEntry = { ...validEntry, identifier: '' } await expect(service.createEntry(invalidEntry)).rejects.toThrow( 'identifier must be a non-empty string' ) }) it('rejects whitespace-only identifier', async () => { const invalidEntry = { ...validEntry, identifier: ' ' } await expect(service.createEntry(invalidEntry)).rejects.toThrow( 'identifier must be a non-empty string' ) }) }) describe('findEntries', () => { it('returns entries filtered by productClass', async () => { const entries = [ { identifier: 'fw-1', productClass: 'BM632w', version: '1.0.0' }, ] vi.mocked(FirmwareEntryModel.findByProductClass).mockResolvedValue(entries as any) const result = await service.findEntries('BM632w') expect(FirmwareEntryModel.findByProductClass).toHaveBeenCalledWith('BM632w') expect(result).toEqual(entries) }) it('returns all entries when no productClass is specified', async () => { const entries = [ { identifier: 'fw-1', productClass: 'BM632w' }, { identifier: 'fw-2', productClass: 'Livebox5' }, ] vi.mocked(FirmwareEntryModel.find).mockResolvedValue(entries as any) const result = await service.findEntries() expect(FirmwareEntryModel.find).toHaveBeenCalledWith({}) expect(result).toEqual(entries) }) }) describe('deleteEntry', () => { it('deletes an existing entry', async () => { vi.mocked(FirmwareEntryModel.deleteByIdentifier).mockResolvedValue(true) await expect(service.deleteEntry('fw-1')).resolves.toBeUndefined() expect(FirmwareEntryModel.deleteByIdentifier).toHaveBeenCalledWith('fw-1') }) it('throws when entry not found', async () => { vi.mocked(FirmwareEntryModel.deleteByIdentifier).mockResolvedValue(false) await expect(service.deleteEntry('nonexistent')).rejects.toThrow( "Firmware entry 'nonexistent' not found" ) }) }) describe('createMigrationRoute', () => { const validRoute: CreateMigrationRouteDto = { identifier: 'BM632w-1.x-to-2.0', productClass: 'BM632w', sourceVersionPattern: '1.*', targetVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.x-to-2.0.bin', priority: 10, } it('creates a migration route with valid data', async () => { const expected = { ...validRoute, createdAt: new Date() } vi.mocked(MigrationRouteModel.createIfNotExists).mockResolvedValue(expected as any) const result = await service.createMigrationRoute(validRoute) expect(MigrationRouteModel.createIfNotExists).toHaveBeenCalledWith({ identifier: validRoute.identifier, productClass: validRoute.productClass, sourceVersionPattern: validRoute.sourceVersionPattern, targetVersion: validRoute.targetVersion, downloadUrl: validRoute.downloadUrl, priority: validRoute.priority, }) expect(result).toEqual(expected) }) it('rejects invalid downloadUrl', async () => { const invalidRoute = { ...validRoute, downloadUrl: 'invalid' } await expect(service.createMigrationRoute(invalidRoute)).rejects.toThrow( 'downloadUrl must be a valid URL' ) }) it('rejects non-positive priority', async () => { const invalidRoute = { ...validRoute, priority: 0 } await expect(service.createMigrationRoute(invalidRoute)).rejects.toThrow( 'priority must be a positive integer' ) }) it('rejects negative priority', async () => { const invalidRoute = { ...validRoute, priority: -5 } await expect(service.createMigrationRoute(invalidRoute)).rejects.toThrow( 'priority must be a positive integer' ) }) it('rejects non-integer priority', async () => { const invalidRoute = { ...validRoute, priority: 2.5 } await expect(service.createMigrationRoute(invalidRoute)).rejects.toThrow( 'priority must be a positive integer' ) }) it('rejects empty identifier', async () => { const invalidRoute = { ...validRoute, identifier: '' } await expect(service.createMigrationRoute(invalidRoute)).rejects.toThrow( 'identifier must be a non-empty string' ) }) }) describe('findMigrationRoutes', () => { it('returns routes sorted by priority descending', async () => { const routes = [ { identifier: 'route-1', priority: 20 }, { identifier: 'route-2', priority: 10 }, ] vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue(routes as any) const result = await service.findMigrationRoutes('BM632w') expect(MigrationRouteModel.findByProductClass).toHaveBeenCalledWith('BM632w', { priority: -1 }) expect(result).toEqual(routes) }) }) describe('deleteMigrationRoute', () => { it('deletes an existing route', async () => { vi.mocked(MigrationRouteModel.deleteByIdentifier).mockResolvedValue(true) await expect(service.deleteMigrationRoute('route-1')).resolves.toBeUndefined() expect(MigrationRouteModel.deleteByIdentifier).toHaveBeenCalledWith('route-1') }) it('throws when route not found', async () => { vi.mocked(MigrationRouteModel.deleteByIdentifier).mockResolvedValue(false) await expect(service.deleteMigrationRoute('nonexistent')).rejects.toThrow( "Migration route 'nonexistent' not found" ) }) }) describe('resolveUpgradePath', () => { it('returns empty array when already at target version', async () => { const result = await service.resolveUpgradePath('BM632w', '2.0.0', '2.0.0') expect(result).toEqual([]) // Should not even query the database expect(MigrationRouteModel.findByProductClass).not.toHaveBeenCalled() }) it('resolves a direct single-step path', async () => { vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue([ { identifier: 'route-1', productClass: 'BM632w', sourceVersionPattern: '1.0.0', targetVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.0-to-2.0.bin', priority: 10, createdAt: new Date(), }, ]) vi.mocked(FirmwareEntryModel.findByProductClassAndVersion).mockResolvedValue({ identifier: 'BM632w-fw-2.0.0', productClass: 'BM632w', version: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/2.0.0.bin', checksum: 'abc123', checksumType: 'md5', fileSize: 1024000, createdAt: new Date(), }) const result = await service.resolveUpgradePath('BM632w', '1.0.0', '2.0.0') expect(result).toEqual([ { fromVersion: '1.0.0', toVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.0-to-2.0.bin', fileSize: 1024000, checksum: 'abc123', }, ]) }) it('resolves a multi-step path', async () => { vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue([ { identifier: 'route-1', productClass: 'BM632w', sourceVersionPattern: '1.0.*', targetVersion: '1.5.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.0-to-1.5.bin', priority: 10, createdAt: new Date(), }, { identifier: 'route-2', productClass: 'BM632w', sourceVersionPattern: '1.5.*', targetVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.5-to-2.0.bin', priority: 10, createdAt: new Date(), }, ]) vi.mocked(FirmwareEntryModel.findByProductClassAndVersion).mockImplementation( async (_pc: string, version: string) => { if (version === '1.5.0') { return { identifier: 'BM632w-fw-1.5.0', productClass: 'BM632w', version: '1.5.0', downloadUrl: 'http://firmware.local/BM632w/1.5.0.bin', checksum: 'hash150', checksumType: 'sha256' as const, fileSize: 800000, createdAt: new Date(), } } if (version === '2.0.0') { return { identifier: 'BM632w-fw-2.0.0', productClass: 'BM632w', version: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/2.0.0.bin', checksum: 'hash200', checksumType: 'md5' as const, fileSize: 1200000, createdAt: new Date(), } } return null } ) const result = await service.resolveUpgradePath('BM632w', '1.0.3', '2.0.0') expect(result).toHaveLength(2) expect(result[0]).toEqual({ fromVersion: '1.0.3', toVersion: '1.5.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.0-to-1.5.bin', fileSize: 800000, checksum: 'hash150', }) expect(result[1]).toEqual({ fromVersion: '1.5.0', toVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.5-to-2.0.bin', fileSize: 1200000, checksum: 'hash200', }) }) it('returns empty array when no path exists', async () => { vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue([ { identifier: 'route-1', productClass: 'BM632w', sourceVersionPattern: '1.0.*', targetVersion: '1.5.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.0-to-1.5.bin', priority: 10, createdAt: new Date(), }, ]) // Trying to reach 3.0.0 but no route from 1.5.0 to 3.0.0 const result = await service.resolveUpgradePath('BM632w', '1.0.0', '3.0.0') expect(result).toEqual([]) }) it('returns empty array when cycle is detected', async () => { vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue([ { identifier: 'route-1', productClass: 'BM632w', sourceVersionPattern: '1.0.0', targetVersion: '1.5.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.0-to-1.5.bin', priority: 10, createdAt: new Date(), }, { identifier: 'route-2', productClass: 'BM632w', sourceVersionPattern: '1.5.0', targetVersion: '1.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade-1.5-to-1.0.bin', priority: 10, createdAt: new Date(), }, ]) const result = await service.resolveUpgradePath('BM632w', '1.0.0', '2.0.0') expect(result).toEqual([]) }) it('selects the highest priority route when multiple apply', async () => { vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue([ { identifier: 'route-low', productClass: 'BM632w', sourceVersionPattern: '1.0.*', targetVersion: '1.5.0', downloadUrl: 'http://firmware.local/BM632w/slow-path.bin', priority: 5, createdAt: new Date(), }, { identifier: 'route-high', productClass: 'BM632w', sourceVersionPattern: '1.*', targetVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/fast-path.bin', priority: 20, createdAt: new Date(), }, ]) vi.mocked(FirmwareEntryModel.findByProductClassAndVersion).mockResolvedValue({ identifier: 'BM632w-fw-2.0.0', productClass: 'BM632w', version: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/2.0.0.bin', checksum: 'directhash', checksumType: 'sha256', fileSize: 2000000, createdAt: new Date(), }) const result = await service.resolveUpgradePath('BM632w', '1.0.5', '2.0.0') expect(result).toHaveLength(1) expect(result[0]).toEqual({ fromVersion: '1.0.5', toVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/fast-path.bin', fileSize: 2000000, checksum: 'directhash', }) }) it('keeps fileSize 0 and checksum empty when no firmware entry exists', async () => { vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue([ { identifier: 'route-1', productClass: 'BM632w', sourceVersionPattern: '1.0.0', targetVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade.bin', priority: 10, createdAt: new Date(), }, ]) vi.mocked(FirmwareEntryModel.findByProductClassAndVersion).mockResolvedValue(null) const result = await service.resolveUpgradePath('BM632w', '1.0.0', '2.0.0') expect(result).toEqual([ { fromVersion: '1.0.0', toVersion: '2.0.0', downloadUrl: 'http://firmware.local/BM632w/upgrade.bin', fileSize: 0, checksum: '', }, ]) }) it('returns empty array when no routes exist for the productClass', async () => { vi.mocked(MigrationRouteModel.findByProductClass).mockResolvedValue([]) const result = await service.resolveUpgradePath('BM632w', '1.0.0', '2.0.0') expect(result).toEqual([]) }) }) })