Add aggregated burndown forecast to CCP Metrics overview page
This commit is contained in:
308
backend/__tests__/vcl-aggregated-burndown.property.test.js
Normal file
308
backend/__tests__/vcl-aggregated-burndown.property.test.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user