Files
adam-acs/tests/property/configuration_partial_update.property.spec.ts
T
Kevin L'HereecandCopilot d1c65dbf1e chore: initial commit — AdonisJS v7 TR-069 ACS
- Docker infrastructure (Dockerfile, docker-compose.yml, genieacs-sim)
- AdonisJS v7 app with Inertia.js + Vue 3
- MongoDB integration
- CWMP/TR-069 state machine engine
- Configuration engine with compliance tracking
- Branding: ADAM ACS (favicon, navbar, locales)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 16:46:37 +02:00

254 lines
8.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import fc from 'fast-check'
import { PolicyType } from '../../app/domain/configuration/configuration.js'
import type { Configuration } from '../../app/domain/configuration/configuration.js'
/**
* Property 3: Partial updates preserve unmodified fields
* Validates: Requirements 1.6, 10.3
*
* When updating only a subset of fields (toggle enabled, add/remove tags),
* all other fields remain unchanged. Verified by inspecting the updateOne
* call payload to ensure only the targeted field is included.
*/
// Mock ConfigurationModel and ConfigurationVersionModel
vi.mock('../../app/models/configuration.js', () => {
return {
default: {
findByIdentifier: vi.fn(),
updateOne: vi.fn(),
findAllEnabled: vi.fn(),
},
}
})
vi.mock('../../app/models/configuration_version.js', () => {
return {
default: {
getLatestVersionNumber: vi.fn(),
create: vi.fn(),
},
}
})
import ConfigurationModel from '../../app/models/configuration.js'
import ConfigurationService from '../../app/services/configuration_service.js'
describe('Property 3: Partial updates preserve unmodified fields', () => {
let service: ConfigurationService
beforeEach(() => {
vi.clearAllMocks()
service = new ConfigurationService()
})
/**
* Arbitrary for generating a valid Configuration object.
*/
const configurationArb: fc.Arbitrary<Configuration> = fc
.record({
identifier: fc
.string({ minLength: 1, maxLength: 20 })
.map((s) => s.replace(/[^A-Za-z0-9_-]/g, 'x') || 'cfg'),
name: fc.string({ minLength: 1, maxLength: 50 }),
version: fc
.tuple(fc.nat({ max: 99 }), fc.nat({ max: 99 }), fc.nat({ max: 99 }))
.map(([a, b, c]) => `${a}.${b}.${c}`),
priority: fc.integer({ min: 1, max: 1000 }),
policy: fc.constantFrom(PolicyType.ONCE, PolicyType.MAINTAIN),
enabled: fc.boolean(),
tags: fc.array(
fc.string({ minLength: 1, maxLength: 15 }).map((s) => s.replace(/\s/g, 'a')),
{ minLength: 0, maxLength: 5 }
),
criteria: fc.record({
manufacturer: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
oui: fc.option(fc.string({ minLength: 1, maxLength: 10 }), { nil: undefined }),
productClass: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
serialNumber: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
}),
inventory: fc.array(
fc.record({
path: fc
.array(fc.string({ minLength: 1, maxLength: 10 }).map((s) => s.replace(/\./g, 'x')), {
minLength: 1,
maxLength: 4,
})
.map((parts) => parts.join('.') + '.'),
}),
{ minLength: 0, maxLength: 3 }
),
desiredParameters: fc.constant([]),
firmwareTarget: fc.option(
fc.record({
productClass: fc.string({ minLength: 1, maxLength: 20 }),
targetVersion: fc.string({ minLength: 1, maxLength: 10 }),
}),
{ nil: undefined }
),
schedule: fc.option(
fc.record({
daysOfWeek: fc.array(fc.integer({ min: 0, max: 6 }), { minLength: 1, maxLength: 7 }),
startHour: fc.integer({ min: 0, max: 23 }),
endHour: fc.integer({ min: 0, max: 23 }),
timezone: fc.constantFrom('Europe/Paris', 'America/New_York', 'UTC'),
}),
{ nil: undefined }
),
createdAt: fc.date(),
updatedAt: fc.date(),
})
.map((rec) => rec as unknown as Configuration)
it('toggleEnabled only changes the enabled field, all other fields remain unchanged', async () => {
await fc.assert(
fc.asyncProperty(configurationArb, fc.boolean(), async (existingConfig, newEnabled) => {
vi.clearAllMocks()
// Track what updateOne receives
let capturedUpdate: Record<string, any> | null = null
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existingConfig as any) // first call: check existence
.mockResolvedValueOnce({ ...existingConfig, enabled: newEnabled } as any) // second call: return updated
vi.mocked(ConfigurationModel.updateOne).mockImplementation(async (_filter, update) => {
capturedUpdate = update as Record<string, any>
return { acknowledged: true, modifiedCount: 1 }
})
await service.toggleEnabled(existingConfig.identifier, newEnabled)
// The updateOne call should only contain the enabled field
expect(capturedUpdate).not.toBeNull()
expect(capturedUpdate!.enabled).toBe(newEnabled)
// Verify no other configuration fields are in the update payload
const updateKeys = Object.keys(capturedUpdate!)
const forbiddenKeys = [
'identifier',
'name',
'version',
'priority',
'policy',
'tags',
'criteria',
'inventory',
'desiredParameters',
'firmwareTarget',
'schedule',
'createdAt',
]
for (const key of forbiddenKeys) {
expect(updateKeys).not.toContain(key)
}
}),
{ numRuns: 100 }
)
})
it('updateTags only changes the tags field, all other fields remain unchanged', async () => {
await fc.assert(
fc.asyncProperty(
configurationArb,
fc.array(fc.string({ minLength: 1, maxLength: 10 }).map((s) => s.replace(/\s/g, 'a')), {
minLength: 0,
maxLength: 3,
}),
fc.array(fc.string({ minLength: 1, maxLength: 10 }).map((s) => s.replace(/\s/g, 'a')), {
minLength: 0,
maxLength: 3,
}),
async (existingConfig, tagsToAdd, tagsToRemove) => {
vi.clearAllMocks()
let capturedUpdate: Record<string, any> | null = null
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existingConfig as any) // first call: check existence
.mockResolvedValueOnce(existingConfig as any) // second call: return after update
vi.mocked(ConfigurationModel.updateOne).mockImplementation(async (_filter, update) => {
capturedUpdate = update as Record<string, any>
return { acknowledged: true, modifiedCount: 1 }
})
await service.updateTags(existingConfig.identifier, tagsToAdd, tagsToRemove)
// The updateOne call should only contain the tags field
expect(capturedUpdate).not.toBeNull()
expect(capturedUpdate!).toHaveProperty('tags')
// Verify no other configuration fields are in the update payload
const updateKeys = Object.keys(capturedUpdate!)
const forbiddenKeys = [
'identifier',
'name',
'version',
'priority',
'policy',
'enabled',
'criteria',
'inventory',
'desiredParameters',
'firmwareTarget',
'schedule',
'createdAt',
]
for (const key of forbiddenKeys) {
expect(updateKeys).not.toContain(key)
}
}
),
{ numRuns: 100 }
)
})
it('updateTags preserves existing tags not targeted by remove operations', async () => {
await fc.assert(
fc.asyncProperty(
configurationArb.map((config) => ({
...config,
tags: ['existing-tag-1', 'existing-tag-2', 'existing-tag-3'],
})),
fc.array(
fc.string({ minLength: 1, maxLength: 10 }).map((s) => 'new-' + s.replace(/\s/g, '')),
{ minLength: 1, maxLength: 2 }
),
async (existingConfig, tagsToAdd) => {
vi.clearAllMocks()
let capturedUpdate: Record<string, any> | null = null
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existingConfig as any)
.mockResolvedValueOnce(existingConfig as any)
vi.mocked(ConfigurationModel.updateOne).mockImplementation(async (_filter, update) => {
capturedUpdate = update as Record<string, any>
return { acknowledged: true, modifiedCount: 1 }
})
// Only add tags, don't remove any
await service.updateTags(existingConfig.identifier, tagsToAdd, [])
expect(capturedUpdate).not.toBeNull()
const updatedTags = capturedUpdate!.tags as string[]
// All original tags should still be present
for (const originalTag of existingConfig.tags) {
expect(updatedTags).toContain(originalTag)
}
// All added tags should be present
for (const addedTag of tagsToAdd) {
expect(updatedTags).toContain(addedTag)
}
}
),
{ numRuns: 100 }
)
})
})