- 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>
1429 lines
50 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|