- 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>
506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
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([])
|
|
})
|
|
})
|
|
})
|