Files
adam-acs/tests/unit/services/configuration_service.spec.ts
T
Kevin L'HereecandCopilot aff8e14a63 feat: compliance report + auto-versioning for configurations
- Add ComplianceStatus.PENDING and configVersion to DeviceComplianceInfo
- Add ComplianceReport + ComplianceDeviceResult types to ConfigurationEngine
- Expose evaluateSpecific() as public (used by compliance + dry-run)
- Add complianceReport() method: filter matching devices, evaluate each
- Fix matchesCriteria: skip parameterCriteria entries with null/empty name
- Fix getDeviceParameterValue: fallback to plain object arrays (DeviceRecord)
- Fix findDevice: support lookup by both identifier and deviceId.id
- ConfigurationService.update: version is now auto-managed (no user input)
  - behavioral changes (criteria/params/policy/priority/…) → patch bump
  - bumpType field allows 'patch' | 'minor' | 'major'
  - cosmetic changes (name/tags) → no bump
- Add sanitizeCriteria: filter null/empty parameterCriteria names silently
- Add isBehavioralChange helper
- Add DeviceModel.resetComplianceForConfiguration call on behavioral update
- Add ConfigurationModel.distinctTags() + controller endpoint GET /tags
- Add controller endpoint GET /configurations/:id/compliance
- Add DeviceModel.getDistinctValues for inventory fields
- Fix unit tests: mock DeviceModel, remove obsolete version-validation test
- Add 14 new unit tests (engine: 5, service: 9)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 11:32:17 +02:00

1429 lines
50 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import ConfigurationService, {
ValidationError,
} from '../../../app/services/configuration_service.js'
import type { CreateConfigurationDto } from '../../../app/services/configuration_service.js'
import { PolicyType } from '../../../app/domain/configuration/configuration.js'
// Mock the models
vi.mock('#models/configuration', () => {
return {
default: {
createIfNotExists: vi.fn(),
findByIdentifier: vi.fn(),
findAllEnabled: vi.fn(),
paginateConfigurations: vi.fn(),
deleteByIdentifier: vi.fn(),
updateOne: vi.fn(),
},
}
})
vi.mock('#models/configuration_version', () => {
return {
default: {
deleteByConfigurationIdentifier: vi.fn(),
getLatestVersionNumber: vi.fn(),
create: vi.fn(),
paginateVersions: vi.fn(),
},
}
})
vi.mock('#models/device', () => {
return {
default: {
resetComplianceForConfiguration: vi.fn(),
},
}
})
import ConfigurationModel from '#models/configuration'
import ConfigurationVersionModel from '#models/configuration_version'
import DeviceModel from '#models/device'
function buildValidDto(overrides: Partial<CreateConfigurationDto> = {}): CreateConfigurationDto {
return {
identifier: 'BM632w-standard',
name: 'Configuration standard BM632w',
version: '1.0.0',
priority: 100,
policy: PolicyType.MAINTAIN,
enabled: true,
tags: ['production'],
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
inventory: [{ path: 'InternetGatewayDevice.ManagementServer.' }],
desiredParameters: [
{
name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval',
value: 3600,
type: 'unsignedInt',
},
],
...overrides,
}
}
describe('ConfigurationService', () => {
let service: ConfigurationService
beforeEach(() => {
service = new ConfigurationService()
vi.clearAllMocks()
// Default mock returns for version model methods used in update
vi.mocked(ConfigurationVersionModel.getLatestVersionNumber).mockResolvedValue(0)
vi.mocked(ConfigurationVersionModel.create).mockResolvedValue({} as any)
vi.mocked(DeviceModel.resetComplianceForConfiguration).mockResolvedValue(undefined)
// Default: no existing configurations (no overlap)
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([])
})
describe('create', () => {
it('creates a valid configuration', async () => {
const dto = buildValidDto()
const expected = { ...dto, createdAt: new Date(), updatedAt: new Date() }
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue(expected as any)
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([])
const result = await service.create(dto)
expect(ConfigurationModel.createIfNotExists).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'BM632w-standard',
name: 'Configuration standard BM632w',
version: '1.0.0',
priority: 100,
policy: PolicyType.MAINTAIN,
enabled: true,
})
)
expect(result).toEqual(expected)
})
it('defaults enabled to true when not provided', async () => {
const dto = buildValidDto({ enabled: undefined })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([])
await service.create(dto)
expect(ConfigurationModel.createIfNotExists).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true })
)
})
it('defaults tags to empty array when not provided', async () => {
const dto = buildValidDto({ tags: undefined })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([])
await service.create(dto)
expect(ConfigurationModel.createIfNotExists).toHaveBeenCalledWith(
expect.objectContaining({ tags: [] })
)
})
})
describe('validation - identifier', () => {
it('rejects empty identifier', async () => {
const dto = buildValidDto({ identifier: '' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects identifier starting with hyphen', async () => {
const dto = buildValidDto({ identifier: '-invalid' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects identifier starting with underscore', async () => {
const dto = buildValidDto({ identifier: '_invalid' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects identifier with spaces', async () => {
const dto = buildValidDto({ identifier: 'has space' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects identifier with special characters', async () => {
const dto = buildValidDto({ identifier: 'invalid@char' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('accepts identifier with hyphens and underscores', async () => {
const dto = buildValidDto({ identifier: 'valid-id_123' })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
it('accepts identifier starting with number', async () => {
const dto = buildValidDto({ identifier: '123-config' })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
})
describe('validation - version (semver)', () => {
it('rejects non-semver version', async () => {
const dto = buildValidDto({ version: '1.0' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects version with prefix', async () => {
const dto = buildValidDto({ version: 'v1.0.0' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects empty version', async () => {
const dto = buildValidDto({ version: '' })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('accepts valid semver', async () => {
const dto = buildValidDto({ version: '2.3.4' })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
})
describe('validation - priority', () => {
it('rejects zero priority', async () => {
const dto = buildValidDto({ priority: 0 })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects negative priority', async () => {
const dto = buildValidDto({ priority: -5 })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects non-integer priority', async () => {
const dto = buildValidDto({ priority: 1.5 })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('accepts positive integer priority', async () => {
const dto = buildValidDto({ priority: 1 })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
})
describe('validation - policy', () => {
it('rejects invalid policy', async () => {
const dto = buildValidDto({ policy: 'invalid' as any })
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('accepts "once" policy', async () => {
const dto = buildValidDto({ policy: PolicyType.ONCE })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
it('accepts "maintain" policy', async () => {
const dto = buildValidDto({ policy: PolicyType.MAINTAIN })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
})
describe('validation - criteria', () => {
it('rejects criteria with non-DeviceId field', async () => {
const dto = buildValidDto({
criteria: { manufacturer: 'Sagemcom', invalidField: 'value' } as any,
})
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('rejects criteria with writable parameter field', async () => {
const dto = buildValidDto({
criteria: { firmwareVersion: '1.0.0' } as any,
})
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('accepts all valid DeviceId fields', async () => {
const dto = buildValidDto({
criteria: {
manufacturer: 'Sagemcom',
oui: '001122',
productClass: 'BM632w',
serialNumber: 'SN123',
},
})
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
it('accepts empty criteria object', async () => {
const dto = buildValidDto({ criteria: {} })
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
})
describe('validation - inventory coverage', () => {
it('rejects desired parameter not covered by inventory', async () => {
const dto = buildValidDto({
inventory: [{ path: 'InternetGatewayDevice.DeviceInfo.' }],
desiredParameters: [
{
name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval',
value: 3600,
type: 'unsignedInt',
},
],
})
await expect(service.create(dto)).rejects.toThrow(ValidationError)
})
it('includes suggestion in error message', async () => {
const dto = buildValidDto({
inventory: [{ path: 'InternetGatewayDevice.DeviceInfo.' }],
desiredParameters: [
{
name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval',
value: 3600,
type: 'unsignedInt',
},
],
})
await expect(service.create(dto)).rejects.toThrow(
/suggest adding "InternetGatewayDevice\.ManagementServer\."/
)
})
it('accepts when all desired parameters are covered', async () => {
const dto = buildValidDto({
inventory: [{ path: 'InternetGatewayDevice.ManagementServer.' }],
desiredParameters: [
{
name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval',
value: 3600,
type: 'unsignedInt',
},
],
})
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
it('accepts empty desired parameters without inventory', async () => {
const dto = buildValidDto({
inventory: [],
desiredParameters: [],
})
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({} as any)
await expect(service.create(dto)).resolves.not.toThrow()
})
})
describe('findAll', () => {
it('calls paginateConfigurations with default page and perPage', async () => {
vi.mocked(ConfigurationModel.paginateConfigurations).mockResolvedValue({
data: [],
total: 0,
page: 1,
perPage: 25,
lastPage: 1,
})
await service.findAll()
expect(ConfigurationModel.paginateConfigurations).toHaveBeenCalledWith(1, 25, {})
})
it('applies tag filter', async () => {
vi.mocked(ConfigurationModel.paginateConfigurations).mockResolvedValue({
data: [],
total: 0,
page: 1,
perPage: 25,
lastPage: 1,
})
await service.findAll({ tag: 'production' })
expect(ConfigurationModel.paginateConfigurations).toHaveBeenCalledWith(1, 25, {
tags: 'production',
})
})
it('applies enabled filter', async () => {
vi.mocked(ConfigurationModel.paginateConfigurations).mockResolvedValue({
data: [],
total: 0,
page: 1,
perPage: 25,
lastPage: 1,
})
await service.findAll({ enabled: true })
expect(ConfigurationModel.paginateConfigurations).toHaveBeenCalledWith(1, 25, {
enabled: true,
})
})
it('applies productClass filter', async () => {
vi.mocked(ConfigurationModel.paginateConfigurations).mockResolvedValue({
data: [],
total: 0,
page: 1,
perPage: 25,
lastPage: 1,
})
await service.findAll({ productClass: 'BM632w' })
expect(ConfigurationModel.paginateConfigurations).toHaveBeenCalledWith(1, 25, {
'criteria.productClass': 'BM632w',
})
})
it('applies multiple filters together', async () => {
vi.mocked(ConfigurationModel.paginateConfigurations).mockResolvedValue({
data: [],
total: 0,
page: 1,
perPage: 25,
lastPage: 1,
})
await service.findAll({ tag: 'prod', enabled: true, productClass: 'BM632w' })
expect(ConfigurationModel.paginateConfigurations).toHaveBeenCalledWith(1, 25, {
tags: 'prod',
enabled: true,
'criteria.productClass': 'BM632w',
})
})
it('uses custom page and perPage', async () => {
vi.mocked(ConfigurationModel.paginateConfigurations).mockResolvedValue({
data: [],
total: 0,
page: 3,
perPage: 10,
lastPage: 1,
})
await service.findAll({ page: 3, perPage: 10 })
expect(ConfigurationModel.paginateConfigurations).toHaveBeenCalledWith(3, 10, {})
})
})
describe('findById', () => {
it('returns configuration when found', async () => {
const config = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(config as any)
const result = await service.findById('BM632w-standard')
expect(ConfigurationModel.findByIdentifier).toHaveBeenCalledWith('BM632w-standard')
expect(result).toEqual(config)
})
it('returns null when not found', async () => {
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(null)
const result = await service.findById('nonexistent')
expect(result).toBeNull()
})
})
describe('update', () => {
it('throws when configuration not found', async () => {
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(null)
await expect(service.update('nonexistent', { name: 'New Name' })).rejects.toThrow(
ValidationError
)
})
it('updates only provided fields', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated Name' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', { name: 'Updated Name' })
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ name: 'Updated Name' }
)
expect(result.name).toBe('Updated Name')
})
it('validates priority on update', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
await expect(service.update('BM632w-standard', { priority: -1 })).rejects.toThrow(
ValidationError
)
})
it('validates criteria on update', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
await expect(
service.update('BM632w-standard', { criteria: { badField: 'x' } as any })
).rejects.toThrow(ValidationError)
})
it('validates inventory coverage with merged data on update', async () => {
const existing = buildValidDto({
inventory: [{ path: 'InternetGatewayDevice.ManagementServer.' }],
desiredParameters: [
{
name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval',
value: 3600,
type: 'unsignedInt',
},
],
})
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
// Changing inventory to one that doesn't cover existing desired params
await expect(
service.update('BM632w-standard', {
inventory: [{ path: 'InternetGatewayDevice.DeviceInfo.' }],
})
).rejects.toThrow(ValidationError)
})
})
describe('update — version auto-management', () => {
it('bumps patch version on behavioral change by default', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', { criteria: updatedCriteria })
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: updatedCriteria })
)
expect(result.version).toBe('1.0.1')
})
it('bumps minor version when bumpType is minor', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.1.0', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', {
criteria: updatedCriteria,
bumpType: 'minor',
})
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.1.0', criteria: updatedCriteria })
)
expect(result.version).toBe('1.1.0')
})
it('bumps major version when bumpType is major', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '2.0.0', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', {
criteria: updatedCriteria,
bumpType: 'major',
})
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '2.0.0', criteria: updatedCriteria })
)
expect(result.version).toBe('2.0.0')
})
it('does not bump version on cosmetic change only', async () => {
const existing = buildValidDto({ version: '1.0.0' })
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated Name' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', { name: 'Updated Name' })
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ name: 'Updated Name' }
)
expect(result.version).toBe('1.0.0')
})
it('calls resetComplianceForConfiguration on behavioral change', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await service.update('BM632w-standard', { criteria: updatedCriteria })
expect(DeviceModel.resetComplianceForConfiguration).toHaveBeenCalledWith(
'BM632w-standard'
)
})
it('does not call resetComplianceForConfiguration on cosmetic change only', async () => {
const existing = buildValidDto({ version: '1.0.0' })
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated Name' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await service.update('BM632w-standard', { name: 'Updated Name' })
expect(DeviceModel.resetComplianceForConfiguration).not.toHaveBeenCalled()
})
})
describe('sanitizeCriteria', () => {
it('filters null names from parameterCriteria on update', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const sanitizedCriteria = { parameterCriteria: undefined }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: sanitizedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await expect(
service.update('BM632w-standard', {
criteria: { parameterCriteria: [{ name: null as any, value: 'x' }] },
})
).resolves.not.toThrow()
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: sanitizedCriteria })
)
})
it('filters empty string names from parameterCriteria on update', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const sanitizedCriteria = { parameterCriteria: undefined }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: sanitizedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await expect(
service.update('BM632w-standard', {
criteria: { parameterCriteria: [{ name: '' as any, value: 'x' }] },
})
).resolves.not.toThrow()
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: sanitizedCriteria })
)
})
it('keeps valid parameterCriteria entries after filtering invalid ones', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const validEntry = {
name: 'InternetGatewayDevice.DeviceInfo.SerialNumber',
value: 'SN123',
}
const sanitizedCriteria = { parameterCriteria: [validEntry] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: sanitizedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await service.update('BM632w-standard', {
criteria: {
parameterCriteria: [{ name: '' as any, value: 'x' }, validEntry],
},
})
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: sanitizedCriteria })
)
})
})
describe('delete', () => {
it('deletes configuration and its versions', async () => {
vi.mocked(ConfigurationModel.deleteByIdentifier).mockResolvedValue(true)
vi.mocked(ConfigurationVersionModel.deleteByConfigurationIdentifier).mockResolvedValue(3)
const result = await service.delete('BM632w-standard')
expect(result).toBe(true)
expect(ConfigurationModel.deleteByIdentifier).toHaveBeenCalledWith('BM632w-standard')
expect(ConfigurationVersionModel.deleteByConfigurationIdentifier).toHaveBeenCalledWith(
'BM632w-standard'
)
})
it('does not delete versions when configuration not found', async () => {
vi.mocked(ConfigurationModel.deleteByIdentifier).mockResolvedValue(false)
const result = await service.delete('nonexistent')
expect(result).toBe(false)
expect(ConfigurationVersionModel.deleteByConfigurationIdentifier).not.toHaveBeenCalled()
})
})
describe('toggleEnabled', () => {
it('toggles enabled to true', async () => {
const existing = buildValidDto({ enabled: false })
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, enabled: true } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.toggleEnabled('BM632w-standard', true)
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ enabled: true }
)
expect(result.enabled).toBe(true)
})
it('toggles enabled to false', async () => {
const existing = buildValidDto({ enabled: true })
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, enabled: false } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.toggleEnabled('BM632w-standard', false)
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ enabled: false }
)
expect(result.enabled).toBe(false)
})
it('throws ValidationError when configuration not found', async () => {
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(null)
await expect(service.toggleEnabled('nonexistent', true)).rejects.toThrow(ValidationError)
})
it('does not modify other fields', async () => {
const existing = buildValidDto({ enabled: false, tags: ['production'], priority: 100 })
const updated = { ...existing, enabled: true }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.toggleEnabled('BM632w-standard', true)
// Only enabled field should be in the update payload
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ enabled: true }
)
expect(result.tags).toEqual(['production'])
expect(result.priority).toBe(100)
})
})
describe('updateTags', () => {
it('adds tags to a configuration', async () => {
const existing = buildValidDto({ tags: ['production'] })
const updated = { ...existing, tags: ['production', 'livebox'] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.updateTags('BM632w-standard', ['livebox'])
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ tags: ['production', 'livebox'] }
)
expect(result.tags).toEqual(['production', 'livebox'])
})
it('removes tags from a configuration', async () => {
const existing = buildValidDto({ tags: ['production', 'livebox', 'test'] })
const updated = { ...existing, tags: ['production'] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.updateTags('BM632w-standard', undefined, ['livebox', 'test'])
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ tags: ['production'] }
)
expect(result.tags).toEqual(['production'])
})
it('adds and removes tags simultaneously', async () => {
const existing = buildValidDto({ tags: ['production', 'old-tag'] })
const updated = { ...existing, tags: ['production', 'new-tag'] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.updateTags('BM632w-standard', ['new-tag'], ['old-tag'])
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ tags: ['production', 'new-tag'] }
)
expect(result.tags).toEqual(['production', 'new-tag'])
})
it('avoids duplicate tags when adding', async () => {
const existing = buildValidDto({ tags: ['production', 'livebox'] })
const updated = { ...existing, tags: ['production', 'livebox'] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.updateTags('BM632w-standard', ['production', 'livebox'])
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ tags: ['production', 'livebox'] }
)
expect(result.tags).toEqual(['production', 'livebox'])
})
it('handles removing non-existent tags gracefully', async () => {
const existing = buildValidDto({ tags: ['production'] })
const updated = { ...existing, tags: ['production'] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.updateTags('BM632w-standard', undefined, ['nonexistent'])
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ tags: ['production'] }
)
expect(result.tags).toEqual(['production'])
})
it('throws ValidationError when configuration not found', async () => {
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(null)
await expect(
service.updateTags('nonexistent', ['tag1'])
).rejects.toThrow(ValidationError)
})
it('does not modify other fields', async () => {
const existing = buildValidDto({ tags: ['production'], enabled: true, priority: 100 })
const updated = { ...existing, tags: ['production', 'new-tag'] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.updateTags('BM632w-standard', ['new-tag'])
// Only tags field should be in the update payload
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ tags: ['production', 'new-tag'] }
)
expect(result.enabled).toBe(true)
expect(result.priority).toBe(100)
})
it('handles empty add and remove arrays', async () => {
const existing = buildValidDto({ tags: ['production'] })
const updated = { ...existing, tags: ['production'] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce(updated as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.updateTags('BM632w-standard', [], [])
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ tags: ['production'] }
)
expect(result.tags).toEqual(['production'])
})
})
describe('version history', () => {
describe('update creates version snapshot', () => {
it('creates a version snapshot before applying update', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated Name' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
vi.mocked(ConfigurationVersionModel.getLatestVersionNumber).mockResolvedValue(0)
vi.mocked(ConfigurationVersionModel.create).mockResolvedValue({} as any)
await service.update('BM632w-standard', { name: 'Updated Name' })
expect(ConfigurationVersionModel.getLatestVersionNumber).toHaveBeenCalledWith(
'BM632w-standard'
)
expect(ConfigurationVersionModel.create).toHaveBeenCalledWith(
expect.objectContaining({
configurationIdentifier: 'BM632w-standard',
versionNumber: 1,
snapshot: existing,
modifiedAt: expect.any(Date),
})
)
})
it('increments version number based on latest existing version', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
vi.mocked(ConfigurationVersionModel.getLatestVersionNumber).mockResolvedValue(5)
vi.mocked(ConfigurationVersionModel.create).mockResolvedValue({} as any)
await service.update('BM632w-standard', { name: 'Updated' })
expect(ConfigurationVersionModel.create).toHaveBeenCalledWith(
expect.objectContaining({
versionNumber: 6,
})
)
})
it('stores the full current state as snapshot before modification', async () => {
const existing = buildValidDto({
name: 'Original Name',
priority: 100,
tags: ['production'],
})
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'New Name', priority: 200 } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
vi.mocked(ConfigurationVersionModel.getLatestVersionNumber).mockResolvedValue(2)
vi.mocked(ConfigurationVersionModel.create).mockResolvedValue({} as any)
await service.update('BM632w-standard', { name: 'New Name', priority: 200 })
expect(ConfigurationVersionModel.create).toHaveBeenCalledWith(
expect.objectContaining({
snapshot: expect.objectContaining({
name: 'Original Name',
priority: 100,
tags: ['production'],
}),
})
)
})
it('creates snapshot before the update is applied', async () => {
const existing = buildValidDto()
const callOrder: string[] = []
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated' } as any)
vi.mocked(ConfigurationVersionModel.getLatestVersionNumber).mockResolvedValue(0)
vi.mocked(ConfigurationVersionModel.create).mockImplementation(async () => {
callOrder.push('createVersion')
return {} as any
})
vi.mocked(ConfigurationModel.updateOne).mockImplementation(async () => {
callOrder.push('updateConfig')
return {} as any
})
await service.update('BM632w-standard', { name: 'Updated' })
expect(callOrder).toEqual(['createVersion', 'updateConfig'])
})
})
describe('getHistory', () => {
it('returns paginated version history', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
const paginatedResult = {
data: [
{
configurationIdentifier: 'BM632w-standard',
versionNumber: 2,
snapshot: existing,
modifiedAt: new Date('2024-01-02'),
},
{
configurationIdentifier: 'BM632w-standard',
versionNumber: 1,
snapshot: existing,
modifiedAt: new Date('2024-01-01'),
},
],
total: 2,
page: 1,
perPage: 25,
lastPage: 1,
}
vi.mocked(ConfigurationVersionModel.paginateVersions).mockResolvedValue(paginatedResult as any)
const result = await service.getHistory('BM632w-standard', 1, 25)
expect(ConfigurationVersionModel.paginateVersions).toHaveBeenCalledWith(
'BM632w-standard',
1,
25
)
expect(result.data).toHaveLength(2)
expect(result.data[0].versionNumber).toBe(2)
expect(result.data[1].versionNumber).toBe(1)
expect(result.total).toBe(2)
})
it('throws ValidationError when configuration not found', async () => {
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(null)
await expect(service.getHistory('nonexistent', 1, 25)).rejects.toThrow(ValidationError)
})
it('uses default pagination values', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
vi.mocked(ConfigurationVersionModel.paginateVersions).mockResolvedValue({
data: [],
total: 0,
page: 1,
perPage: 25,
lastPage: 1,
})
await service.getHistory('BM632w-standard')
expect(ConfigurationVersionModel.paginateVersions).toHaveBeenCalledWith(
'BM632w-standard',
1,
25
)
})
it('supports custom page and limit', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
vi.mocked(ConfigurationVersionModel.paginateVersions).mockResolvedValue({
data: [],
total: 0,
page: 3,
perPage: 10,
lastPage: 1,
})
await service.getHistory('BM632w-standard', 3, 10)
expect(ConfigurationVersionModel.paginateVersions).toHaveBeenCalledWith(
'BM632w-standard',
3,
10
)
})
})
describe('delete removes all versions', () => {
it('deletes all associated versions when configuration is deleted', async () => {
vi.mocked(ConfigurationModel.deleteByIdentifier).mockResolvedValue(true)
vi.mocked(ConfigurationVersionModel.deleteByConfigurationIdentifier).mockResolvedValue(5)
await service.delete('BM632w-standard')
expect(ConfigurationVersionModel.deleteByConfigurationIdentifier).toHaveBeenCalledWith(
'BM632w-standard'
)
})
it('does not attempt to delete versions when configuration does not exist', async () => {
vi.mocked(ConfigurationModel.deleteByIdentifier).mockResolvedValue(false)
await service.delete('nonexistent')
expect(ConfigurationVersionModel.deleteByConfigurationIdentifier).not.toHaveBeenCalled()
})
})
})
describe('detectOverlap', () => {
it('returns null when no existing configurations', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
expect(result).toBeNull()
})
it('returns null when no overlap with existing configurations', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'other-config',
priority: 100,
enabled: true,
criteria: { manufacturer: 'Huawei', productClass: 'HG8245' },
} as any,
])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
expect(result).toBeNull()
})
it('throws ValidationError with 409 for same-priority overlap', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'existing-config',
priority: 100,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
])
await expect(
service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
).rejects.toThrow(ValidationError)
try {
await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
} catch (error: any) {
expect(error.statusCode).toBe(409)
expect(error.message).toContain('existing-config')
}
})
it('returns warning for different-priority overlap', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'existing-config',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
expect(result).toEqual({
type: 'overlap_different_priority',
overlappingConfigurations: [{ identifier: 'existing-config', priority: 50 }],
})
})
it('excludes the specified configuration by excludeId', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'self-config',
priority: 100,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100,
'self-config'
)
expect(result).toBeNull()
})
it('detects overlap when one criteria has no field (matches all)', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'broad-config',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom' }, // no productClass → matches all
} as any,
])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
expect(result).toEqual({
type: 'overlap_different_priority',
overlappingConfigurations: [{ identifier: 'broad-config', priority: 50 }],
})
})
it('detects overlap with glob patterns', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'glob-config',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM*' },
} as any,
])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
expect(result).toEqual({
type: 'overlap_different_priority',
overlappingConfigurations: [{ identifier: 'glob-config', priority: 50 }],
})
})
it('does not detect overlap when patterns are clearly disjoint', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'other-config',
priority: 100,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'HG*' },
} as any,
])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
expect(result).toBeNull()
})
it('reports multiple overlapping configurations', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'config-a',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
{
identifier: 'config-b',
priority: 200,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM*' },
} as any,
])
const result = await service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
expect(result).toEqual({
type: 'overlap_different_priority',
overlappingConfigurations: expect.arrayContaining([
{ identifier: 'config-a', priority: 50 },
{ identifier: 'config-b', priority: 200 },
]),
})
expect(result!.overlappingConfigurations).toHaveLength(2)
})
it('prioritizes same-priority rejection over different-priority warning', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'same-priority-config',
priority: 100,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
{
identifier: 'diff-priority-config',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
])
await expect(
service.detectOverlap(
{ manufacturer: 'Sagemcom', productClass: 'BM632w' },
100
)
).rejects.toThrow(ValidationError)
})
it('detects overlap when both criteria are empty (match all devices)', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'catch-all',
priority: 1,
enabled: true,
criteria: {},
} as any,
])
const result = await service.detectOverlap({}, 100)
expect(result).toEqual({
type: 'overlap_different_priority',
overlappingConfigurations: [{ identifier: 'catch-all', priority: 1 }],
})
})
})
describe('create with overlap detection', () => {
it('includes warning in result for different-priority overlap', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'existing-config',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
])
const dto = buildValidDto()
vi.mocked(ConfigurationModel.createIfNotExists).mockResolvedValue({
...dto,
createdAt: new Date(),
updatedAt: new Date(),
} as any)
const result = await service.create(dto)
expect((result as any).warning).toEqual({
type: 'overlap_different_priority',
overlappingConfigurations: [{ identifier: 'existing-config', priority: 50 }],
})
})
it('rejects creation with 409 for same-priority overlap', async () => {
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'existing-config',
priority: 100,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
])
const dto = buildValidDto()
try {
await service.create(dto)
expect.fail('Should have thrown')
} catch (error: any) {
expect(error).toBeInstanceOf(ValidationError)
expect(error.statusCode).toBe(409)
expect(error.message).toContain('existing-config')
}
})
})
describe('update with overlap detection', () => {
it('includes warning when criteria change causes different-priority overlap', async () => {
const existing = buildValidDto({ priority: 100 })
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, criteria: { manufacturer: 'Sagemcom', productClass: 'HG*' } } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'other-config',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'HG8245' },
} as any,
])
const result = await service.update('BM632w-standard', {
criteria: { manufacturer: 'Sagemcom', productClass: 'HG*' },
})
expect((result as any).warning).toEqual({
type: 'overlap_different_priority',
overlappingConfigurations: [{ identifier: 'other-config', priority: 50 }],
})
})
it('rejects update with 409 when priority change causes same-priority overlap', async () => {
const existing = buildValidDto({ priority: 100 })
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([
{
identifier: 'other-config',
priority: 50,
enabled: true,
criteria: { manufacturer: 'Sagemcom', productClass: 'BM632w' },
} as any,
])
try {
await service.update('BM632w-standard', { priority: 50 })
expect.fail('Should have thrown')
} catch (error: any) {
expect(error).toBeInstanceOf(ValidationError)
expect(error.statusCode).toBe(409)
expect(error.message).toContain('other-config')
}
})
it('does not check overlap when neither criteria nor priority changed', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated Name' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await service.update('BM632w-standard', { name: 'Updated Name' })
expect(ConfigurationModel.findAllEnabled).not.toHaveBeenCalled()
})
})
})