309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
/**
|
|
* 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 }
|
|
);
|
|
});
|
|
});
|