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 { 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() }) }) })