/** * Property-Based Tests: VCL Aggregated Burndown * * Feature: vcl-aggregated-burndown * * Tests the pure helper functions `deduplicateByHostname` and `computeAggregatedBurndown` * from `backend/helpers/vclHelpers.js`. * * Validates: Requirements 1.5, 1.6, 1.7, 2.2, 2.3, 2.4, 2.5, 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.4 */ const fc = require('fast-check'); // Mock db pool before importing anything jest.mock('../db', () => ({ query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), connect: jest.fn(() => Promise.resolve({ query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), release: jest.fn(), })), })); jest.mock('../helpers/auditLog', () => jest.fn()); jest.mock('../helpers/ivantiApi', () => ({ ivantiFormPost: jest.fn(), ivantiPost: jest.fn(), })); const { deduplicateByHostname, computeAggregatedBurndown, } = require('../helpers/vclHelpers'); // --- Generators --- const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 20 }); const validDateArb = fc.record({ year: fc.integer({ min: 2020, max: 2030 }), month: fc.integer({ min: 1, max: 12 }), day: fc.integer({ min: 1, max: 28 }), }).map(({ year, month, day }) => `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` ); const verticalCodeArb = fc.constantFrom('NTS_AEO', 'SDIT_CISO', 'TSI', 'SR', 'AllOthers'); const deviceArb = fc.record({ hostname: hostnameArb, resolution_date: fc.oneof(fc.constant(null), validDateArb), vertical: verticalCodeArb, }); // Generator for items that may have duplicate hostnames (for deduplication testing) const duplicateItemsArb = fc.array( fc.record({ hostname: fc.constantFrom('srv-001', 'srv-002', 'srv-003', 'srv-004', 'srv-005'), resolution_date: fc.oneof(fc.constant(null), validDateArb), vertical: verticalCodeArb, }), { minLength: 0, maxLength: 30 } ); // --- Property 1: Partition Invariant --- describe('Feature: vcl-aggregated-burndown, Property 1: Partition Invariant', () => { /** * For any array of device objects passed to computeAggregatedBurndown, * blockers + with_dates = total. * * **Validates: Requirements 2.2** */ it('blockers + with_dates = total for any input', () => { fc.assert( fc.property( fc.array(deviceArb, { minLength: 0, maxLength: 50 }), (devices) => { const result = computeAggregatedBurndown(devices); expect(result.blockers + result.with_dates).toBe(result.total); } ), { numRuns: 100 } ); }); }); // --- Property 2: Monthly Bucket Conservation --- describe('Feature: vcl-aggregated-burndown, Property 2: Monthly Bucket Conservation', () => { /** * For any array of device objects, the sum of all values in monthly * must equal with_dates. * * **Validates: Requirements 2.3, 1.5** */ it('sum of monthly values = with_dates', () => { fc.assert( fc.property( fc.array(deviceArb, { minLength: 0, maxLength: 50 }), (devices) => { const result = computeAggregatedBurndown(devices); const monthlySum = Object.values(result.monthly).reduce((s, v) => s + v, 0); expect(monthlySum).toBe(result.with_dates); } ), { numRuns: 100 } ); }); }); // --- Property 3: Chronological Monthly Ordering --- describe('Feature: vcl-aggregated-burndown, Property 3: Chronological Monthly Ordering', () => { /** * For any array of device objects, the keys of monthly must be in * ascending chronological order (lexicographic sort of YYYY-MM strings). * * **Validates: Requirements 2.4** */ it('monthly keys are in ascending chronological order', () => { fc.assert( fc.property( fc.array(deviceArb, { minLength: 0, maxLength: 50 }), (devices) => { const result = computeAggregatedBurndown(devices); const keys = Object.keys(result.monthly); for (let i = 1; i < keys.length; i++) { expect(keys[i - 1] < keys[i]).toBe(true); } } ), { numRuns: 100 } ); }); }); // --- Property 4: Cumulative Projection Consistency --- describe('Feature: vcl-aggregated-burndown, Property 4: Cumulative Projection Consistency', () => { /** * For any array of device objects, projection[month].remaining = * total - (cumulative sum of monthly[m] for all m <= month). * * **Validates: Requirements 2.5** */ it('projection remaining = total - cumulative remediated', () => { fc.assert( fc.property( fc.array(deviceArb, { minLength: 0, maxLength: 50 }), (devices) => { const result = computeAggregatedBurndown(devices); const months = Object.keys(result.monthly); let cumulative = 0; for (const month of months) { cumulative += result.monthly[month]; expect(result.projection[month].remediated).toBe(result.monthly[month]); expect(result.projection[month].remaining).toBe(result.total - cumulative); } } ), { numRuns: 100 } ); }); }); // --- Property 5: Projected Clear Date Logic --- describe('Feature: vcl-aggregated-burndown, Property 5: Projected Clear Date Logic', () => { /** * If blockers > 0, projected_clear_date must be null. * If blockers = 0 and with_dates > 0, projected_clear_date must equal the last month key. * * **Validates: Requirements 1.7** */ it('null when blockers > 0, last month key when blockers = 0 and with_dates > 0', () => { fc.assert( fc.property( fc.array(deviceArb, { minLength: 0, maxLength: 50 }), (devices) => { const result = computeAggregatedBurndown(devices); if (result.blockers > 0) { expect(result.projected_clear_date).toBeNull(); } else if (result.with_dates > 0) { const months = Object.keys(result.monthly); expect(result.projected_clear_date).toBe(months[months.length - 1]); } else { // total = 0 case expect(result.projected_clear_date).toBeNull(); } } ), { numRuns: 100 } ); }); }); // --- Property 6: Hostname Deduplication with Earliest Date --- describe('Feature: vcl-aggregated-burndown, Property 6: Hostname Deduplication with Earliest Date', () => { /** * For any array of items where the same hostname appears multiple times, * deduplicateByHostname produces exactly one entry per unique hostname, * and that entry's resolution_date is the earliest non-null date (or null if all null). * * **Validates: Requirements 1.6** */ it('one entry per hostname with earliest non-null date', () => { fc.assert( fc.property( duplicateItemsArb, (items) => { const result = deduplicateByHostname(items); // One entry per unique hostname const uniqueHostnames = new Set(items.map(i => i.hostname)); expect(result.length).toBe(uniqueHostnames.size); // Each result hostname appears exactly once const resultHostnames = result.map(r => r.hostname); expect(new Set(resultHostnames).size).toBe(result.length); // For each hostname, verify the date is the earliest non-null for (const entry of result) { const allForHost = items.filter(i => i.hostname === entry.hostname); const nonNullDates = allForHost .map(i => i.resolution_date) .filter(d => d != null); if (nonNullDates.length === 0) { expect(entry.resolution_date).toBeNull(); } else { const earliest = nonNullDates.sort()[0]; expect(entry.resolution_date).toBe(earliest); } } } ), { numRuns: 100 } ); }); }); // --- Property 7: Aggregation Consistency with Per-Vertical Computation --- describe('Feature: vcl-aggregated-burndown, Property 7: Aggregation Consistency with Per-Vertical Computation', () => { /** * Aggregated total = sum of per-vertical totals. * Aggregated blockers = sum of per-vertical blockers. * Aggregated with_dates = sum of per-vertical with_dates. * * **Validates: Requirements 4.1, 4.2, 4.3, 4.4** */ it('aggregated totals = sum of per-vertical totals', () => { fc.assert( fc.property( fc.array(deviceArb, { minLength: 0, maxLength: 50 }), (devices) => { const result = computeAggregatedBurndown(devices); const sumTotal = result.by_vertical.reduce((s, v) => s + v.total, 0); const sumBlockers = result.by_vertical.reduce((s, v) => s + v.blockers, 0); const sumWithDates = result.by_vertical.reduce((s, v) => s + v.with_dates, 0); expect(sumTotal).toBe(result.total); expect(sumBlockers).toBe(result.blockers); expect(sumWithDates).toBe(result.with_dates); } ), { numRuns: 100 } ); }); }); // --- Property 8: By-Vertical Sorting and Filtering --- describe('Feature: vcl-aggregated-burndown, Property 8: By-Vertical Sorting and Filtering', () => { /** * by_vertical is sorted descending by total, contains no zero-total entries, * and the sum of all by_vertical[i].total equals the overall total. * * **Validates: Requirements 5.1, 5.2, 5.4** */ it('sorted descending by total, no zero entries, sum = total', () => { fc.assert( fc.property( fc.array(deviceArb, { minLength: 0, maxLength: 50 }), (devices) => { const result = computeAggregatedBurndown(devices); // Sorted descending by total for (let i = 1; i < result.by_vertical.length; i++) { expect(result.by_vertical[i - 1].total).toBeGreaterThanOrEqual(result.by_vertical[i].total); } // No zero-total entries for (const v of result.by_vertical) { expect(v.total).toBeGreaterThan(0); } // Sum = overall total const sum = result.by_vertical.reduce((s, v) => s + v.total, 0); expect(sum).toBe(result.total); } ), { numRuns: 100 } ); }); });