/** * Property-Based Tests: VCL Compliance Reporting * * Feature: vcl-compliance-reporting * * Tests the pure helper functions used for VCL compliance reporting computations. * * Validates: Requirements 2.4, 2.5, 3.2, 3.3, 5.2, 5.3, 6.1, 6.3, 7.5, 8.2, 8.3, 8.4, 8.7, 9.2, 9.3, 9.6 */ const fc = require('fast-check'); // Mock db pool before importing anything (avoids DATABASE_URL requirement) 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(), })), })); // Mock dependencies that the route module imports jest.mock('../helpers/auditLog', () => jest.fn()); jest.mock('../helpers/ivantiApi', () => ({ ivantiFormPost: jest.fn(), ivantiPost: jest.fn(), })); const { truncateText, validateRemediationPlan, computeVCLStats, formatPct, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders, isValidDateString, } = require('../helpers/vclHelpers'); // --- Generators --- const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 30 }); 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 }), // 1-28 always valid }).map(({ year, month, day }) => `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` ); const complianceItemArb = fc.record({ hostname: hostnameArb, is_compliant: fc.boolean(), in_scope: fc.constant(true), }); const nonCompliantItemArb = fc.record({ hostname: hostnameArb, resolution_date: fc.oneof(fc.constant(null), validDateArb), }); const verticalArb = fc.record({ vertical: fc.string({ minLength: 1, maxLength: 20 }), team: fc.string({ minLength: 1, maxLength: 20 }), non_compliant: fc.integer({ min: 0, max: 1000 }), }); // --- Property 2: Text Truncation --- describe('Feature: vcl-compliance-reporting, Property 2: Text Truncation', () => { /** * For any string, truncateText(text, 80) should return the original string if its * length is <= 80, or the first 80 characters followed by "…" if its length exceeds 80. * * **Validates: Requirements 2.4** */ it('returns original for short strings, truncated + ellipsis for long strings', () => { fc.assert( fc.property( fc.string({ minLength: 0, maxLength: 200 }), fc.integer({ min: 1, max: 100 }), (text, maxLen) => { const result = truncateText(text, maxLen); if (text.length <= maxLen) { expect(result).toBe(text); } else { expect(result).toBe(text.slice(0, maxLen) + '\u2026'); expect(result.length).toBe(maxLen + 1); } } ), { numRuns: 100 } ); }); }); // --- Property 3: Remediation Plan Length Validation --- describe('Feature: vcl-compliance-reporting, Property 3: Remediation Plan Length Validation', () => { /** * For any string, validateRemediationPlan(text) should return valid if and only if * the string length is <= 2000 characters. * * **Validates: Requirements 2.5, 9.4** */ it('accepts strings <= 2000 chars, rejects longer', () => { fc.assert( fc.property( fc.string({ minLength: 1, maxLength: 3000 }), (text) => { const result = validateRemediationPlan(text); if (text.length <= 2000) { expect(result.valid).toBe(true); } else { expect(result.valid).toBe(false); expect(result.error).toBeDefined(); } } ), { numRuns: 100 } ); }); }); // --- Property 4: Summary Statistics Computation Invariants --- describe('Feature: vcl-compliance-reporting, Property 4: Summary Statistics Computation Invariants', () => { /** * For any set of compliance items, computeVCLStats produces correct arithmetic: * non_compliant + compliant = in_scope, and correct percentage. * * **Validates: Requirements 3.2, 7.3** */ it('non_compliant + compliant = in_scope, correct percentage', () => { fc.assert( fc.property( fc.array(complianceItemArb, { minLength: 0, maxLength: 50 }), fc.integer({ min: 0, max: 100 }), (items, targetPct) => { const stats = computeVCLStats(items, targetPct); // in_scope items are those with in_scope === true const in_scope = items.filter(i => i.in_scope).length; const compliant = items.filter(i => i.is_compliant).length; expect(stats.non_compliant + stats.compliant).toBe(stats.in_scope); expect(stats.in_scope).toBe(in_scope); expect(stats.compliant).toBe(compliant); if (in_scope > 0) { expect(stats.compliance_pct).toBe(Math.round((compliant / in_scope) * 100)); } else { expect(stats.compliance_pct).toBe(0); } expect(stats.target_pct).toBe(targetPct); } ), { numRuns: 100 } ); }); }); // --- Property 5: Percentage Formatting --- describe('Feature: vcl-compliance-reporting, Property 5: Percentage Formatting', () => { /** * For any decimal number between 0 and 1, formatPct produces a string matching /^\d{1,3}%$/. * * **Validates: Requirements 3.3** */ it('produces correct percentage string matching /^\\d{1,3}%$/', () => { fc.assert( fc.property( fc.double({ min: 0, max: 1, noNaN: true }), (decimal) => { const result = formatPct(decimal); expect(result).toMatch(/^\d{1,3}%$/); expect(result).toBe(Math.round(decimal * 100) + '%'); } ), { numRuns: 100 } ); }); }); // --- Property 6: Non-Compliant Device Categorization Partition --- describe('Feature: vcl-compliance-reporting, Property 6: Non-Compliant Device Categorization Partition', () => { /** * For any array of non-compliant device objects, categorizeNonCompliant produces * two groups (blocked, in_progress) where blocked.count + in_progress.count = items.length. * * **Validates: Requirements 5.2, 5.3** */ it('two groups sum to total', () => { fc.assert( fc.property( fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }), (items) => { const result = categorizeNonCompliant(items); expect(result.blocked.count + result.in_progress.count).toBe(items.length); if (items.length > 0) { expect(result.blocked.pct).toBe(Math.round((result.blocked.count / items.length) * 100)); expect(result.in_progress.pct).toBe(Math.round((result.in_progress.count / items.length) * 100)); } else { expect(result.blocked.pct).toBe(0); expect(result.in_progress.pct).toBe(0); } } ), { numRuns: 100 } ); }); }); // --- Property 7: Heavy Hitters Descending Sort --- describe('Feature: vcl-compliance-reporting, Property 7: Heavy Hitters Descending Sort', () => { /** * For any array of vertical objects, rankHeavyHitters returns the array sorted * in non-increasing order by non_compliant. * * **Validates: Requirements 6.1, 6.3** */ it('sorted non-increasing by non_compliant', () => { fc.assert( fc.property( fc.array(verticalArb, { minLength: 0, maxLength: 30 }), (verticals) => { const result = rankHeavyHitters(verticals); expect(result.length).toBe(verticals.length); for (let i = 1; i < result.length; i++) { expect(result[i - 1].non_compliant).toBeGreaterThanOrEqual(result[i].non_compliant); } } ), { numRuns: 100 } ); }); }); // --- Property 8: Forecasted Burndown Projection --- describe('Feature: vcl-compliance-reporting, Property 8: Forecasted Burndown Projection', () => { /** * For any set of non-compliant devices with resolution_date values, * computeForecastBurndown produces monthly buckets where the sum of all * monthly forecast counts equals the number of items with non-null resolution_dates. * * **Validates: Requirements 7.5** */ it('bucket sum = count of items with non-null resolution_dates', () => { fc.assert( fc.property( fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }), (items) => { const buckets = computeForecastBurndown(items); const bucketSum = Object.values(buckets).reduce((sum, count) => sum + count, 0); const itemsWithDate = items.filter(i => i.resolution_date != null).length; expect(bucketSum).toBe(itemsWithDate); } ), { numRuns: 100 } ); }); }); // --- Property 9: Hostname Matching with Unmatched Flagging --- describe('Feature: vcl-compliance-reporting, Property 9: Hostname Matching with Unmatched Flagging', () => { /** * For any array of uploaded rows and a set of existing hostnames, * matchByHostname produces matched + unmatched = total, and matched hostnames * all exist in the set. * * **Validates: Requirements 8.2, 8.7** */ it('matched + unmatched = total, matched hostnames in set', () => { fc.assert( fc.property( fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 30 }), fc.array(hostnameArb, { minLength: 0, maxLength: 20 }), (rows, existingList) => { const existingSet = new Set(existingList); const { matched, unmatched } = matchByHostname(rows, existingSet); expect(matched.length + unmatched.length).toBe(rows.length); for (const row of matched) { expect(existingSet.has(row.hostname)).toBe(true); } for (const row of unmatched) { expect(existingSet.has(row.hostname)).toBe(false); } } ), { numRuns: 100 } ); }); }); // --- Property 10: Bulk Diff Change Detection --- describe('Feature: vcl-compliance-reporting, Property 10: Bulk Diff Change Detection', () => { /** * For any array of matched row pairs, computeBulkDiff flags a row as "changed" * if and only if at least one field value differs. * * **Validates: Requirements 8.3, 8.4** */ it('changed iff at least one field differs', () => { const fieldValueArb = fc.oneof(fc.constant(null), fc.string({ minLength: 1, maxLength: 20 })); // When uploaded values match current data exactly, status should be 'unchanged' fc.assert( fc.property( fc.array(hostnameArb, { minLength: 1, maxLength: 20 }).chain(hostnames => { // Ensure unique hostnames to avoid map overwrite issues const uniqueHostnames = [...new Set(hostnames)]; return fc.tuple( ...uniqueHostnames.map(h => fc.record({ hostname: fc.constant(h), resolution_date: fieldValueArb, remediation_plan: fieldValueArb, notes: fieldValueArb, }) ) ); }), (matchedRows) => { // Build currentData with same values as uploaded const currentData = new Map(); for (const row of matchedRows) { currentData.set(row.hostname, { resolution_date: row.resolution_date, remediation_plan: row.remediation_plan, notes: row.notes, }); } const results = computeBulkDiff(matchedRows, currentData); for (const r of results) { expect(r.status).toBe('unchanged'); } } ), { numRuns: 50 } ); // When at least one field differs, status should be 'changed' fc.assert( fc.property( hostnameArb, fc.string({ minLength: 1, maxLength: 20 }), fc.string({ minLength: 1, maxLength: 20 }), (hostname, oldVal, newVal) => { fc.pre(oldVal !== newVal); const matchedRows = [{ hostname, resolution_date: newVal }]; const currentData = new Map(); currentData.set(hostname, { resolution_date: oldVal, remediation_plan: null, notes: null }); const results = computeBulkDiff(matchedRows, currentData); expect(results[0].status).toBe('changed'); } ), { numRuns: 50 } ); }); }); // --- Property 11: Column Header Mapping --- describe('Feature: vcl-compliance-reporting, Property 11: Column Header Mapping', () => { /** * mapColumnHeaders correctly identifies known columns case-insensitively. * * **Validates: Requirements 9.2** */ it('identifies known columns case-insensitively', () => { const knownHeaders = ['Hostname', 'Resolution Date', 'Remediation Plan', 'Notes', 'hostname', 'resolution_date', 'remediation_plan', 'notes', 'HOSTNAME', 'RESOLUTION DATE', 'REMEDIATION PLAN', 'NOTES']; const caseVariantArb = fc.constantFrom(...knownHeaders); const unknownHeaderArb = fc.stringMatching(/^[a-z]{5,10}$/).filter( s => !['hostname', 'notes'].includes(s.toLowerCase()) ); fc.assert( fc.property( fc.array(fc.oneof(caseVariantArb, unknownHeaderArb), { minLength: 1, maxLength: 10 }), (headers) => { const mapping = mapColumnHeaders(headers); // Every mapped key should be a known field const validKeys = new Set(['hostname', 'resolution_date', 'remediation_plan', 'notes']); for (const key of Object.keys(mapping)) { expect(validKeys.has(key)).toBe(true); } // Check that known headers are mapped correctly for (let i = 0; i < headers.length; i++) { const normalized = headers[i].trim().toLowerCase(); if (normalized === 'hostname') { expect(mapping.hostname).toBeDefined(); } if (normalized === 'resolution date' || normalized === 'resolution_date') { expect(mapping.resolution_date).toBeDefined(); } if (normalized === 'remediation plan' || normalized === 'remediation_plan') { expect(mapping.remediation_plan).toBeDefined(); } if (normalized === 'notes') { expect(mapping.notes).toBeDefined(); } } } ), { numRuns: 100 } ); }); }); // --- Property 12: Date String Validation --- describe('Feature: vcl-compliance-reporting, Property 12: Date String Validation', () => { /** * isValidDateString rejects invalid calendar dates and non-date strings. * Returns true only for valid YYYY-MM-DD dates. * * **Validates: Requirements 9.3** */ it('rejects invalid dates and non-date strings', () => { // Valid dates should return true fc.assert( fc.property(validDateArb, (dateStr) => { expect(isValidDateString(dateStr)).toBe(true); }), { numRuns: 50 } ); // Invalid dates should return false const invalidDateArb = fc.oneof( fc.constant(null), fc.constant(''), fc.constant('not-a-date'), fc.constant('2026-02-30'), fc.constant('2026-13-01'), fc.constant('2026-00-15'), fc.constant('abcd-ef-gh'), fc.integer().map(n => String(n)), fc.string({ minLength: 1, maxLength: 5 }), ); fc.assert( fc.property(invalidDateArb, (val) => { expect(isValidDateString(val)).toBe(false); }), { numRuns: 50 } ); }); }); // --- Property 13: Row Count Arithmetic Invariant --- describe('Feature: vcl-compliance-reporting, Property 13: Row Count Arithmetic (matched + unmatched = total)', () => { /** * For any bulk upload, matched + unmatched = total input rows. * * **Validates: Requirements 9.6** */ it('matched + unmatched = total invariant holds', () => { fc.assert( fc.property( fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 50 }), fc.array(hostnameArb, { minLength: 0, maxLength: 30 }), (rows, existingList) => { const existingSet = new Set(existingList); const { matched, unmatched } = matchByHostname(rows, existingSet); // Core invariant: matched + unmatched = total expect(matched.length + unmatched.length).toBe(rows.length); } ), { numRuns: 100 } ); }); });