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 = 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 | 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 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 | 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 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 | 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 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 } ) }) })