Add aggregated burndown forecast to CCP Metrics overview page
This commit is contained in:
371
backend/__tests__/vcl-aggregated-burndown.test.js
Normal file
371
backend/__tests__/vcl-aggregated-burndown.test.js
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Unit and Integration Tests: VCL Aggregated Burndown
|
||||
*
|
||||
* Feature: vcl-aggregated-burndown
|
||||
*
|
||||
* Tests cover:
|
||||
* - deduplicateByHostname edge cases
|
||||
* - computeAggregatedBurndown edge cases
|
||||
* - GET /burndown endpoint with mocked DB
|
||||
* - Empty DB returns zero/empty response
|
||||
* - All-blocker scenario
|
||||
* - Auth middleware enforcement
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => {
|
||||
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
jest.mock('../helpers/ivantiApi', () => ({
|
||||
ivantiFormPost: jest.fn(),
|
||||
ivantiPost: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock driftChecker
|
||||
jest.mock('../helpers/driftChecker', () => ({
|
||||
loadConfig: jest.fn(() => ({})),
|
||||
compareSchemaToDrift: jest.fn(() => null),
|
||||
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
||||
}));
|
||||
|
||||
const mockPool = {
|
||||
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('../db', () => mockPool);
|
||||
|
||||
const {
|
||||
deduplicateByHostname,
|
||||
computeAggregatedBurndown,
|
||||
} = require('../helpers/vclHelpers');
|
||||
|
||||
const { createVCLMultiVerticalRouter } = require('../routes/vclMultiVertical');
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
||||
function request(server, method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const rawBody = Buffer.concat(chunks).toString();
|
||||
let json;
|
||||
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
|
||||
resolve({ statusCode: res.statusCode, body: json });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
let app, server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const mockUpload = { array: () => (req, res, next) => next() };
|
||||
const router = createVCLMultiVerticalRouter(mockUpload);
|
||||
app.use('/api/compliance/vcl-multi', router);
|
||||
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPool.query.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
// --- deduplicateByHostname unit tests ---
|
||||
|
||||
describe('deduplicateByHostname', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(deduplicateByHostname([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes through single item unchanged', () => {
|
||||
const items = [{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }];
|
||||
const result = deduplicateByHostname(items);
|
||||
expect(result).toEqual([{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }]);
|
||||
});
|
||||
|
||||
it('deduplicates by hostname keeping earliest non-null date', () => {
|
||||
const items = [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-08-15', vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
||||
{ hostname: 'srv-001', resolution_date: '2026-07-10', vertical: 'TSI' },
|
||||
];
|
||||
const result = deduplicateByHostname(items);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hostname).toBe('srv-001');
|
||||
expect(result[0].resolution_date).toBe('2026-06-01');
|
||||
});
|
||||
|
||||
it('returns null date when all entries for a hostname have null dates', () => {
|
||||
const items = [
|
||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||
];
|
||||
const result = deduplicateByHostname(items);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].resolution_date).toBeNull();
|
||||
});
|
||||
|
||||
it('picks earliest non-null date even when some entries are null', () => {
|
||||
const items = [
|
||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-001', resolution_date: '2026-09-01', vertical: 'SDIT_CISO' },
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
||||
];
|
||||
const result = deduplicateByHostname(items);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].resolution_date).toBe('2026-06-15');
|
||||
});
|
||||
|
||||
it('preserves vertical from the first entry', () => {
|
||||
const items = [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
||||
];
|
||||
const result = deduplicateByHostname(items);
|
||||
expect(result[0].vertical).toBe('NTS_AEO');
|
||||
});
|
||||
});
|
||||
|
||||
// --- computeAggregatedBurndown unit tests ---
|
||||
|
||||
describe('computeAggregatedBurndown', () => {
|
||||
it('returns zero/empty for empty input', () => {
|
||||
const result = computeAggregatedBurndown([]);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.blockers).toBe(0);
|
||||
expect(result.with_dates).toBe(0);
|
||||
expect(result.monthly).toEqual({});
|
||||
expect(result.projection).toEqual({});
|
||||
expect(result.projected_clear_date).toBeNull();
|
||||
expect(result.by_vertical).toEqual([]);
|
||||
});
|
||||
|
||||
it('all blockers — with_dates=0, monthly={}, projected_clear_date=null', () => {
|
||||
const devices = [
|
||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||
];
|
||||
const result = computeAggregatedBurndown(devices);
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.blockers).toBe(3);
|
||||
expect(result.with_dates).toBe(0);
|
||||
expect(result.monthly).toEqual({});
|
||||
expect(result.projected_clear_date).toBeNull();
|
||||
});
|
||||
|
||||
it('single device with date — correct monthly bucket and projection', () => {
|
||||
const devices = [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||
];
|
||||
const result = computeAggregatedBurndown(devices);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.blockers).toBe(0);
|
||||
expect(result.with_dates).toBe(1);
|
||||
expect(result.monthly).toEqual({ '2026-06': 1 });
|
||||
expect(result.projection).toEqual({ '2026-06': { remediated: 1, remaining: 0 } });
|
||||
expect(result.projected_clear_date).toBe('2026-06');
|
||||
});
|
||||
|
||||
it('mixed blockers and in-progress — projected_clear_date is null', () => {
|
||||
const devices = [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
];
|
||||
const result = computeAggregatedBurndown(devices);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.blockers).toBe(1);
|
||||
expect(result.with_dates).toBe(1);
|
||||
expect(result.projected_clear_date).toBeNull();
|
||||
});
|
||||
|
||||
it('multiple months — correct cumulative projection', () => {
|
||||
const devices = [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-002', resolution_date: '2026-06-20', vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-003', resolution_date: '2026-07-10', vertical: 'SDIT_CISO' },
|
||||
{ hostname: 'srv-004', resolution_date: '2026-08-01', vertical: 'TSI' },
|
||||
];
|
||||
const result = computeAggregatedBurndown(devices);
|
||||
expect(result.total).toBe(4);
|
||||
expect(result.monthly).toEqual({ '2026-06': 2, '2026-07': 1, '2026-08': 1 });
|
||||
expect(result.projection['2026-06'].remaining).toBe(2); // 4 - 2
|
||||
expect(result.projection['2026-07'].remaining).toBe(1); // 4 - 3
|
||||
expect(result.projection['2026-08'].remaining).toBe(0); // 4 - 4
|
||||
expect(result.projected_clear_date).toBe('2026-08');
|
||||
});
|
||||
|
||||
it('by_vertical sorted descending by total, omits zero-total verticals', () => {
|
||||
const devices = [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-003', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-004', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
||||
];
|
||||
const result = computeAggregatedBurndown(devices);
|
||||
expect(result.by_vertical[0].vertical).toBe('NTS_AEO');
|
||||
expect(result.by_vertical[0].total).toBe(3);
|
||||
expect(result.by_vertical[1].vertical).toBe('TSI');
|
||||
expect(result.by_vertical[1].total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- GET /burndown endpoint tests ---
|
||||
|
||||
describe('GET /api/compliance/vcl-multi/burndown', () => {
|
||||
it('returns zero/empty response when no active devices exist', async () => {
|
||||
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.total_non_compliant).toBe(0);
|
||||
expect(res.body.blockers).toBe(0);
|
||||
expect(res.body.with_dates).toBe(0);
|
||||
expect(res.body.monthly_forecast).toEqual({});
|
||||
expect(res.body.projected_clear_date).toBeNull();
|
||||
expect(res.body.by_vertical).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns correct burndown data with mocked DB rows', async () => {
|
||||
mockPool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-002', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'SDIT_CISO' }, // duplicate hostname
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// srv-001 deduplicated: earliest date is 2026-06-15
|
||||
expect(res.body.total_non_compliant).toBe(3); // srv-001, srv-002, srv-003
|
||||
expect(res.body.blockers).toBe(1); // srv-003
|
||||
expect(res.body.with_dates).toBe(2); // srv-001, srv-002
|
||||
expect(res.body.monthly_forecast['2026-06']).toBe(1);
|
||||
expect(res.body.monthly_forecast['2026-07']).toBe(1);
|
||||
expect(res.body.projected_clear_date).toBeNull(); // blockers > 0
|
||||
});
|
||||
|
||||
it('returns all-blocker response correctly', async () => {
|
||||
mockPool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||
{ hostname: 'srv-002', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.total_non_compliant).toBe(2);
|
||||
expect(res.body.blockers).toBe(2);
|
||||
expect(res.body.with_dates).toBe(0);
|
||||
expect(res.body.monthly_forecast).toEqual({});
|
||||
expect(res.body.projected_clear_date).toBeNull();
|
||||
});
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
mockPool.query.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
|
||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toBe('Database error');
|
||||
});
|
||||
|
||||
it('response shape matches API contract', async () => {
|
||||
mockPool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toHaveProperty('total_non_compliant');
|
||||
expect(res.body).toHaveProperty('blockers');
|
||||
expect(res.body).toHaveProperty('with_dates');
|
||||
expect(res.body).toHaveProperty('monthly_forecast');
|
||||
expect(res.body).toHaveProperty('projected_clear_date');
|
||||
expect(res.body).toHaveProperty('by_vertical');
|
||||
expect(Array.isArray(res.body.by_vertical)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auth enforcement test ---
|
||||
|
||||
describe('GET /burndown — auth enforcement', () => {
|
||||
it('returns 401 when auth middleware rejects', async () => {
|
||||
// Create a separate app with rejecting auth
|
||||
const rejectApp = express();
|
||||
rejectApp.use(express.json());
|
||||
|
||||
// Override requireAuth to reject
|
||||
jest.resetModules();
|
||||
jest.doMock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
},
|
||||
requireGroup: () => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
const { createVCLMultiVerticalRouter: createRouter } = require('../routes/vclMultiVertical');
|
||||
const mockUpload = { array: () => (req, res, next) => next() };
|
||||
const router = createRouter(mockUpload);
|
||||
rejectApp.use('/api/compliance/vcl-multi', router);
|
||||
|
||||
const rejectServer = await new Promise((resolve) => {
|
||||
const s = rejectApp.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await request(rejectServer, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.error).toBe('Authentication required');
|
||||
} finally {
|
||||
await new Promise((resolve) => rejectServer.close(resolve));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user