Files
cve-dashboard/backend/__tests__/compliance-duplicate-chart-entries.property.test.js

1949 lines
86 KiB
JavaScript
Raw Normal View History

/**
* Bug Condition Exploration Property Tests: Compliance Duplicate Chart Entries
*
* Spec: .kiro/specs/compliance-duplicate-chart-entries/ (bugfix)
*
* BUG CONDITION (from design.md):
* EXISTS report_date d WHERE COUNT(compliance_uploads WHERE report_date = d) > 1
*
* Five compliance code paths share the root cause "key by `compliance_uploads.id`
* instead of by `compliance_uploads.report_date`":
* - GET /trends (Property 1, test case 1.A)
* - GET /top-recurring (Property 2, test case 1.B)
* - GET /category-trend (Property 3, test case 1.C)
* - GET /summary (Property 4, test case 1.D)
* - persistUpload() snapshots (Property 5, test case 1.E)
*
* THIS TEST SUITE IS EXPECTED TO FAIL ON UNFIXED CODE.
* Failure of these five test cases is the SUCCESS CASE for the exploration
* each failure is a counterexample that confirms the corresponding manifestation
* of the bug exists. After the five fixes from design.md are implemented,
* these same cases will pass and become regression guards.
*
* Each case is anchored on the canonical fixture
* (`fixture_multi_vertical_single_date` from design.md) AND wrapped in a
* fast-check `fc.assert` against `arbScenario` so the property is also
* exercised on randomly-generated multi-vertical scenarios with colliding
* `report_date`s.
*
* **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11**
*/
const http = require('http');
const express = require('express');
const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) ---
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());
// Programmable pg pool: each test installs a query handler that matches the
// actual SQL fragments emitted by backend/routes/compliance.js. The default
// handler returns an empty rowset.
let queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
const recordedQueries = [];
const mockPool = {
query: jest.fn((text, params) => {
recordedQueries.push({ text, params, on: 'pool' });
return queryHandler(text, params);
}),
connect: jest.fn(() => Promise.resolve({
query: jest.fn((text, params) => {
recordedQueries.push({ text, params, on: 'client' });
return queryHandler(text, params);
}),
release: jest.fn(),
})),
};
jest.mock('../db', () => mockPool);
const { createComplianceRouter, persistUpload } = require('../routes/compliance');
// --- HTTP helper ---
function request(server, method, urlPath, body) {
return new Promise((resolve, reject) => {
const addr = server.address();
const options = {
hostname: '127.0.0.1',
port: addr.port,
path: urlPath,
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 raw = Buffer.concat(chunks).toString();
let json;
try { json = JSON.parse(raw); } catch { json = null; }
resolve({ statusCode: res.statusCode, body: json });
});
});
req.on('error', reject);
if (body !== undefined) req.write(JSON.stringify(body));
req.end();
});
}
// --- Pool router: dispatch by SQL substring/regex ---
/**
* Build a query handler from an ordered list of routes. Each route's `match`
* is a substring or RegExp tested against the SQL text. The first match wins.
* `rows` may be a static array or a function (text, params) => rows.
*/
function makeQueryHandler(routes) {
return (text, params) => {
for (const route of routes) {
const target = route.match;
const hit = target instanceof RegExp ? target.test(text) : text.includes(target);
if (hit) {
const rows = typeof route.rows === 'function'
? (route.rows(text, params) || [])
: (route.rows || []);
return Promise.resolve({ rows, rowCount: rows.length });
}
}
return Promise.resolve({ rows: [], rowCount: 0 });
};
}
// --- Fixture builders (per design.md "Test Fixtures Required") ---
const TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
const CATEGORIES = ['Patching', 'Configuration', 'Vulnerability', 'Other'];
/**
* fixture_multi_vertical_single_date three uploads sharing 2025-05-11.
* Reproduces the original GitLab #12 scenario.
*
* Each upload has a distinct vertical (NTS_AEO, SDIT_CISO, TSI), distinct
* counts, and 6 items spread across two teams (STEAM, ACCESS-ENG) and two
* categories (Patching, Configuration). Item layout per upload:
* STEAM / Patching x2
* STEAM / Configuration x1
* ACCESS-ENG / Patching x1
* ACCESS-ENG / Configuration x2
* Per-date aggregate (3 uploads): 9 STEAM, 9 ACCESS-ENG, 9 Patching, 9 Configuration.
*/
function fixtureMultiVerticalSingleDate() {
const verticals = ['NTS_AEO', 'SDIT_CISO', 'TSI'];
const uploads = verticals.map((v, idx) => ({
id: 300 + idx,
report_date: '2025-05-11',
vertical: v,
new_count: 3 + idx, // 3, 4, 5 sum = 12
recurring_count: 7 + idx * 2, // 7, 9, 11 sum = 27
resolved_count: 1 + idx, // 1, 2, 3 sum = 6
uploaded_at: `2025-05-11T${10 + idx}:00:00Z`,
summary_json: JSON.stringify({
entries: [{ team: 'STEAM', metric: 'patching', score: 80 + idx }],
overall_scores: { patching: 80 + idx },
}),
}));
const items = [];
let itemId = 2000;
const layout = [
{ team: 'STEAM', category: 'Patching' },
{ team: 'STEAM', category: 'Patching' },
{ team: 'STEAM', category: 'Configuration' },
{ team: 'ACCESS-ENG', category: 'Patching' },
{ team: 'ACCESS-ENG', category: 'Configuration' },
{ team: 'ACCESS-ENG', category: 'Configuration' },
];
for (const u of uploads) {
for (const l of layout) {
items.push({
id: itemId++,
upload_id: u.id,
hostname: `${u.vertical}-host-${itemId}`,
team: l.team,
category: l.category,
vertical: u.vertical,
status: 'active',
});
}
}
return { uploads, items };
}
/**
* fixture_cross_vertical_items two disjoint sets of compliance_items.
* NTS_AEO contributes 100 hosts on team STEAM.
* SDIT_CISO contributes 50 hosts on team ACCESS-ENG.
* Used to exercise the persistUpload() vertical-isolation property (1.E).
*/
function fixtureCrossVerticalItems() {
const items = [];
let id = 5000;
for (let i = 1; i <= 100; i++) {
items.push({
id: id++,
hostname: `nts-aeo-host-${i}`,
team: 'STEAM',
vertical: 'NTS_AEO',
status: 'active',
});
}
for (let i = 1; i <= 50; i++) {
items.push({
id: id++,
hostname: `sdit-ciso-host-${i}`,
team: 'ACCESS-ENG',
vertical: 'SDIT_CISO',
status: 'active',
});
}
return items;
}
// --- fast-check arbitraries (design.md fixture_pbt_generators, restricted to
// scenarios where the bug condition holds: at least one report_date has
// two or more upload rows). ---
const arbReportDate = fc.constantFrom('2025-05-04', '2025-05-11', '2025-05-18');
const arbVertical = fc.constantFrom('NTS_AEO', 'SDIT_CISO', 'TSI', null);
const arbUpload = fc.record({
report_date: arbReportDate,
vertical: arbVertical,
new_count: fc.integer({ min: 0, max: 30 }),
recurring_count: fc.integer({ min: 0, max: 30 }),
resolved_count: fc.integer({ min: 0, max: 30 }),
});
/**
* arbScenario a list of compliance_uploads rows where at least one
* report_date appears in two or more rows (i.e., the bug condition holds).
* The pre-condition is enforced post-generation via filter() so fast-check
* shrinking still finds simple counterexamples.
*/
const arbScenario = fc.array(arbUpload, { minLength: 2, maxLength: 6 })
.filter(arr => {
const counts = {};
for (const u of arr) counts[u.report_date] = (counts[u.report_date] || 0) + 1;
return Object.values(counts).some(c => c > 1);
})
.map((rawUploads) => rawUploads.map((u, i) => ({
id: 1000 + i,
uploaded_at: `${u.report_date}T${10 + i}:00:00Z`,
summary_json: JSON.stringify({
entries: [{ team: 'STEAM', metric: 'patching', score: 80 }],
overall_scores: { patching: 80 },
}),
...u,
})));
/**
* arbScenarioWithItems arbScenario plus a small set of compliance_items
* (one to four per upload), used by the /category-trend property test.
*/
const arbScenarioWithItems = arbScenario.chain(uploads => {
const itemArrays = uploads.map(() => fc.array(
fc.record({
team: fc.constantFrom(...TEAMS),
category: fc.constantFrom(...CATEGORIES),
}),
{ minLength: 1, maxLength: 4 },
));
return fc.tuple(fc.constant(uploads), fc.tuple(...itemArrays))
.map(([ups, perUpload]) => {
const items = [];
let itemId = 9000;
ups.forEach((u, i) => {
for (const it of perUpload[i]) {
items.push({
id: itemId++,
upload_id: u.id,
hostname: `host-${itemId}`,
vertical: u.vertical,
status: 'active',
...it,
});
}
});
return { uploads: ups, items };
});
});
// --- Shared mock builders for the four read endpoints ---
/**
* Build a query handler that simulates the database state given a list of
* uploads and a list of items. The handler responds to BOTH the unfixed and
* fixed shapes of the SQL the unfixed shape returns one row per upload,
* the fixed shape groups by report_date so the same test code is
* meaningful when re-run against fixed code in tasks 3.2 / 4.2 / 5.2.
*
* The actual SQL fragments are taken verbatim from backend/routes/compliance.js.
*/
function installReadEndpointHandler(uploads, items) {
queryHandler = makeQueryHandler([
// ----- /trends and /top-recurring primary uploads listing -----
// Unfixed: SELECT id, report_date, ..., COALESCE(...) AS total_active FROM compliance_uploads ORDER BY report_date ASC
// Fixed: SELECT report_date, SUM(...) ... GROUP BY report_date ORDER BY report_date ASC
{
match: /FROM\s+compliance_uploads(?!\s+cu\b)[\s\S]*ORDER\s+BY\s+report_date\s+ASC/i,
rows: (text) => {
if (/GROUP\s+BY\s+report_date/i.test(text)) {
// FIXED shape: aggregate by report_date
const byDate = {};
for (const u of uploads) {
const d = u.report_date;
if (!byDate[d]) {
byDate[d] = {
report_date: d,
new_count: 0,
recurring_count: 0,
resolved_count: 0,
total_active: 0,
};
}
byDate[d].new_count += u.new_count;
byDate[d].recurring_count += u.recurring_count;
byDate[d].resolved_count += u.resolved_count;
byDate[d].total_active += u.new_count + u.recurring_count;
}
return Object.values(byDate).sort((a, b) =>
a.report_date.localeCompare(b.report_date),
);
}
// UNFIXED shape: one row per upload
return [...uploads]
.sort((a, b) => a.report_date.localeCompare(b.report_date))
.map(u => ({
id: u.id,
report_date: u.report_date,
new_count: u.new_count,
recurring_count: u.recurring_count,
resolved_count: u.resolved_count,
total_active: u.new_count + u.recurring_count,
}));
},
},
// ----- /trends per-team item-count query -----
// Unfixed: SELECT ci.upload_id, ci.team, COUNT(ci.id)::int FROM compliance_items ci WHERE ci.team IS NOT NULL GROUP BY ci.upload_id, ci.team
// Fixed: SELECT cu.report_date, ci.team, COUNT(...) FROM compliance_items ci JOIN compliance_uploads cu ON ci.upload_id = cu.id WHERE ci.team IS NOT NULL ... GROUP BY cu.report_date, ci.team
{
match: /FROM\s+compliance_items\s+ci\b[\s\S]*?WHERE\s+ci\.team\s+IS\s+NOT\s+NULL/i,
rows: (text) => {
if (/GROUP\s+BY\s+cu\.report_date/i.test(text)) {
// FIXED shape
const grouped = {};
for (const it of items) {
if (!it.team) continue;
const u = uploads.find(x => x.id === it.upload_id);
if (!u || u.report_date == null) continue;
const k = `${u.report_date}|${it.team}`;
grouped[k] = (grouped[k] || 0) + 1;
}
return Object.entries(grouped).map(([k, count]) => {
const [report_date, team] = k.split('|');
return { report_date, team, count };
});
}
// UNFIXED shape: keyed by upload_id
const grouped = {};
for (const it of items) {
if (!it.team) continue;
const k = `${it.upload_id}|${it.team}`;
grouped[k] = (grouped[k] || 0) + 1;
}
return Object.entries(grouped).map(([k, count]) => {
const [upload_id, team] = k.split('|');
return { upload_id: Number(upload_id), team, count };
});
},
},
// ----- /category-trend -----
// Unfixed: SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id)::int AS count
// FROM compliance_uploads cu JOIN compliance_items ci ON ci.upload_id = cu.id
// GROUP BY cu.id, cu.report_date, category ORDER BY cu.report_date ASC
// Fixed: ... GROUP BY cu.report_date, COALESCE(ci.category, 'Unknown') ORDER BY ...
{
match: /FROM\s+compliance_uploads\s+cu\s+JOIN\s+compliance_items\s+ci/i,
rows: (text) => {
if (/GROUP\s+BY\s+cu\.id/i.test(text)) {
// UNFIXED shape: group by (upload_id, date, category) → duplicate rows per date
const grouped = {};
for (const it of items) {
const u = uploads.find(x => x.id === it.upload_id);
if (!u || u.report_date == null) continue;
const cat = it.category || 'Unknown';
const k = `${u.id}|${u.report_date}|${cat}`;
grouped[k] = (grouped[k] || 0) + 1;
}
return Object.entries(grouped)
.map(([k, count]) => {
const [, report_date, category] = k.split('|');
return { report_date, category, count };
})
.sort((a, b) =>
a.report_date.localeCompare(b.report_date) ||
a.category.localeCompare(b.category),
);
}
// FIXED shape: group by (date, category)
const grouped = {};
for (const it of items) {
const u = uploads.find(x => x.id === it.upload_id);
if (!u || u.report_date == null) continue;
const cat = it.category || 'Unknown';
const k = `${u.report_date}|${cat}`;
grouped[k] = (grouped[k] || 0) + 1;
}
return Object.entries(grouped)
.map(([k, count]) => {
const [report_date, category] = k.split('|');
return { report_date, category, count };
})
.sort((a, b) =>
a.report_date.localeCompare(b.report_date) ||
a.category.localeCompare(b.category),
);
},
},
// ----- /summary primary upload selection -----
// Unfixed: WHERE vertical IS NULL ORDER BY id DESC LIMIT 1
{
match: /WHERE\s+vertical\s+IS\s+NULL\s+ORDER\s+BY\s+id\s+DESC\s+LIMIT\s+1/i,
rows: () => {
const candidates = uploads
.filter(u => u.vertical == null && u.summary_json)
.sort((a, b) => b.id - a.id);
if (candidates.length === 0) return [];
const u = candidates[0];
return [{
id: u.id,
summary_json: u.summary_json,
report_date: u.report_date,
uploaded_at: u.uploaded_at,
}];
},
},
// Unfixed fallback: WHERE vertical = 'NTS_AEO' ORDER BY id DESC LIMIT 1
{
match: /WHERE\s+vertical\s*=\s*'NTS_AEO'\s+ORDER\s+BY\s+id\s+DESC\s+LIMIT\s+1/i,
rows: () => {
const candidates = uploads
.filter(u => u.vertical === 'NTS_AEO' && u.summary_json)
.sort((a, b) => b.id - a.id);
if (candidates.length === 0) return [];
const u = candidates[0];
return [{
id: u.id,
summary_json: u.summary_json,
report_date: u.report_date,
uploaded_at: u.uploaded_at,
}];
},
},
// Fixed-side: sibling-disclosure query
// SELECT id, vertical, uploaded_at FROM compliance_uploads WHERE report_date = $1 AND id != $2 ORDER BY id ASC
{
match: /WHERE\s+report_date\s*=\s*\$1\s+AND\s+id\s*!=\s*\$2/i,
rows: (_text, params) => {
const [reportDate, excludeId] = params || [];
return uploads
.filter(u => u.report_date === reportDate && u.id !== excludeId)
.sort((a, b) => a.id - b.id)
.map(u => ({ id: u.id, vertical: u.vertical, uploaded_at: u.uploaded_at }));
},
},
]);
}
// --- Express server setup ---
let app, server;
beforeAll((done) => {
app = express();
app.use(express.json());
const mockUpload = { single: () => (req, res, next) => next() };
app.use('/api/compliance', createComplianceRouter(mockUpload));
server = app.listen(0, '127.0.0.1', done);
});
afterAll((done) => {
server.close(done);
});
beforeEach(() => {
mockPool.query.mockClear();
mockPool.connect.mockClear();
recordedQueries.length = 0;
queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
});
// =============================================================================
// Test Case 1.A — Property 1: Bug Condition — `/trends` returns one entry
// per unique report_date with summed counts and
// aggregated per-team totals.
// =============================================================================
//
// EXPECTED COUNTEREXAMPLE on UNFIXED code:
// With three compliance_uploads for 2025-05-11 (NTS_AEO, SDIT_CISO, TSI),
// GET /trends returns three entries with report_date='2025-05-11' instead
// of one. The handler runs `SELECT id, report_date, ... FROM
// compliance_uploads ORDER BY report_date ASC` and `.map()`s each row into
// a trend entry; per-team counts are pre-aggregated by upload_id and
// looked up by `u.id`, so duplicate-date rows produce duplicate-date
// trend entries with split per-team counts.
//
// **Validates: Requirements 1.1, 1.2, 1.3**
//
describe('Bug Condition / Property 1 — GET /trends aggregates uploads by report_date', () => {
it('1.A canonical fixture — exactly one entry per unique report_date with summed counts and aggregated per-team totals', async () => {
const { uploads, items } = fixtureMultiVerticalSingleDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/trends');
expect(res.statusCode).toBe(200);
// (1) Exactly one entry per unique report_date
const targetDate = '2025-05-11';
const matchingTrends = res.body.trends.filter(t => t.report_date === targetDate);
expect(matchingTrends).toHaveLength(1);
// (2) Counts equal SUM across all uploads sharing that date
const targetUploads = uploads.filter(u => u.report_date === targetDate);
const expectedNew = targetUploads.reduce((s, u) => s + u.new_count, 0);
const expectedRecurring = targetUploads.reduce((s, u) => s + u.recurring_count, 0);
const expectedResolved = targetUploads.reduce((s, u) => s + u.resolved_count, 0);
const expectedTotalActive = targetUploads.reduce((s, u) => s + u.new_count + u.recurring_count, 0);
const trend = matchingTrends[0];
expect(trend.new_count).toBe(expectedNew);
expect(trend.recurring_count).toBe(expectedRecurring);
expect(trend.resolved_count).toBe(expectedResolved);
expect(trend.total_active).toBe(expectedTotalActive);
// (3) Per-team counts equal SUM across uploads sharing that date.
// Layout per upload: 3 STEAM, 3 ACCESS-ENG. Three uploads share the
// date → 9 STEAM, 9 ACCESS-ENG, 0 ACCESS-OPS, 0 INTELDEV.
const targetItems = items.filter(it =>
targetUploads.some(u => u.id === it.upload_id),
);
const expectedSTEAM = targetItems.filter(it => it.team === 'STEAM').length;
const expectedAccessEng = targetItems.filter(it => it.team === 'ACCESS-ENG').length;
const expectedAccessOps = targetItems.filter(it => it.team === 'ACCESS-OPS').length;
const expectedIntelDev = targetItems.filter(it => it.team === 'INTELDEV').length;
expect(trend.STEAM).toBe(expectedSTEAM);
expect(trend['ACCESS-ENG']).toBe(expectedAccessEng);
expect(trend['ACCESS-OPS']).toBe(expectedAccessOps);
expect(trend.INTELDEV).toBe(expectedIntelDev);
});
it('1.A property — GET /trends returns one entry per unique report_date for any multi-vertical scenario', async () => {
await fc.assert(
fc.asyncProperty(arbScenario, async (uploads) => {
installReadEndpointHandler(uploads, []);
const res = await request(server, 'GET', '/api/compliance/trends');
expect(res.statusCode).toBe(200);
const uniqueDates = new Set(uploads.map(u => u.report_date));
expect(res.body.trends).toHaveLength(uniqueDates.size);
// For each unique date, the entry's counts are the SUM
// across all uploads sharing that date.
for (const date of uniqueDates) {
const sharing = uploads.filter(u => u.report_date === date);
const entry = res.body.trends.find(t => t.report_date === date);
expect(entry).toBeDefined();
expect(entry.new_count).toBe(sharing.reduce((s, u) => s + u.new_count, 0));
expect(entry.recurring_count).toBe(sharing.reduce((s, u) => s + u.recurring_count, 0));
expect(entry.resolved_count).toBe(sharing.reduce((s, u) => s + u.resolved_count, 0));
}
}),
{ numRuns: 25 },
);
});
});
// =============================================================================
// Test Case 1.B — Property 2: Bug Condition — `/top-recurring` waterfall has
// one bar per unique report_date with correct
// running totals.
// =============================================================================
//
// EXPECTED COUNTEREXAMPLE on UNFIXED code:
// Three uploads for 2025-05-11 produce three waterfall bars labelled
// "2025-05-11". Worse, computeWaterfall() carries `start` forward across
// the three rows, so the second and third bars' start reflects the prior
// row's end inside the same date — the running totals misrepresent the
// date-level deltas. The fix aggregates uploads to one row per date
// before passing to computeWaterfall().
//
// **Validates: Requirements 1.4, 1.5**
//
describe('Bug Condition / Property 2 — GET /top-recurring has one bar per unique report_date with running invariant', () => {
it('1.B canonical fixture — exactly one waterfall entry per unique report_date and running invariant holds', async () => {
const { uploads, items } = fixtureMultiVerticalSingleDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/top-recurring');
expect(res.statusCode).toBe(200);
const wf = res.body.waterfall;
// (1) One waterfall entry per unique report_date
const dates = wf.map(w => w.date);
const uniqueDates = new Set(uploads.map(u => u.report_date));
expect(wf).toHaveLength(uniqueDates.size);
expect(new Set(dates).size).toBe(uniqueDates.size);
// (2) Running invariant: entry[0].start === 0 AND
// entry[i].end === entry[i].start + new_count + recurring_count - resolved_count
// entry[i].start === entry[i-1].end (for i >= 1)
expect(wf[0].start).toBe(0);
for (let i = 0; i < wf.length; i++) {
expect(wf[i].end).toBe(
wf[i].start + wf[i].new_count + wf[i].recurring_count - wf[i].resolved_count,
);
if (i > 0) {
expect(wf[i].start).toBe(wf[i - 1].end);
}
}
// (3) For 2025-05-11, the new/recurring/resolved counts equal the
// SUM across all uploads sharing that date.
const target = wf.find(w => w.date === '2025-05-11');
const sharing = uploads.filter(u => u.report_date === '2025-05-11');
expect(target.new_count).toBe(sharing.reduce((s, u) => s + u.new_count, 0));
expect(target.recurring_count).toBe(sharing.reduce((s, u) => s + u.recurring_count, 0));
expect(target.resolved_count).toBe(sharing.reduce((s, u) => s + u.resolved_count, 0));
});
it('1.B property — waterfall has exactly one entry per unique report_date and the running invariant always holds', async () => {
await fc.assert(
fc.asyncProperty(arbScenario, async (uploads) => {
installReadEndpointHandler(uploads, []);
const res = await request(server, 'GET', '/api/compliance/top-recurring');
expect(res.statusCode).toBe(200);
const wf = res.body.waterfall;
const uniqueDates = new Set(uploads.map(u => u.report_date));
expect(wf).toHaveLength(uniqueDates.size);
if (wf.length > 0) {
expect(wf[0].start).toBe(0);
}
for (let i = 0; i < wf.length; i++) {
expect(wf[i].end).toBe(
wf[i].start + wf[i].new_count + wf[i].recurring_count - wf[i].resolved_count,
);
if (i > 0) {
expect(wf[i].start).toBe(wf[i - 1].end);
}
}
}),
{ numRuns: 25 },
);
});
});
// =============================================================================
// Test Case 1.C — Property 3: Bug Condition — `/category-trend` returns one
// row per (report_date, category) pair.
// =============================================================================
//
// EXPECTED COUNTEREXAMPLE on UNFIXED code:
// The query `GROUP BY cu.id, cu.report_date, category` keeps `cu.id` in
// the grouping, so three uploads for 2025-05-11 each produce their own
// (date, category) rows. With items in two categories, the response
// contains 3 × 2 = 6 rows for 2025-05-11 instead of 2.
//
// **Validates: Requirements 1.6, 1.7**
//
describe('Bug Condition / Property 3 — GET /category-trend returns one row per (date, category)', () => {
it('1.C canonical fixture — exactly one row per (report_date, category) and counts equal the SUM across uploads sharing the date', async () => {
const { uploads, items } = fixtureMultiVerticalSingleDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/category-trend');
expect(res.statusCode).toBe(200);
// (1) Exactly one row for ('2025-05-11', 'Patching')
const patching = res.body.categoryTrend.filter(c =>
c.report_date === '2025-05-11' && c.category === 'Patching',
);
expect(patching).toHaveLength(1);
// (2) That row's count equals the total compliance_items in
// 'Patching' across every upload sharing the date.
const expectedPatchingCount = items.filter(it =>
uploads.some(u => u.id === it.upload_id && u.report_date === '2025-05-11') &&
it.category === 'Patching',
).length;
expect(patching[0].count).toBe(expectedPatchingCount);
// (3) Same for 'Configuration'.
const configuration = res.body.categoryTrend.filter(c =>
c.report_date === '2025-05-11' && c.category === 'Configuration',
);
expect(configuration).toHaveLength(1);
const expectedConfigCount = items.filter(it =>
uploads.some(u => u.id === it.upload_id && u.report_date === '2025-05-11') &&
it.category === 'Configuration',
).length;
expect(configuration[0].count).toBe(expectedConfigCount);
});
it('1.C property — for any random multi-vertical scenario, every (date, category) appears at most once', async () => {
await fc.assert(
fc.asyncProperty(arbScenarioWithItems, async ({ uploads, items }) => {
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/category-trend');
expect(res.statusCode).toBe(200);
// (1) Each (date, category) pair appears at most once.
const seen = new Set();
for (const row of res.body.categoryTrend) {
const key = `${row.report_date}|${row.category}`;
expect(seen.has(key)).toBe(false);
seen.add(key);
}
// (2) For every (date, category) pair, the count equals the
// SUM of compliance_items in that category across all
// uploads sharing the date.
for (const row of res.body.categoryTrend) {
const expected = items.filter(it => {
const u = uploads.find(x => x.id === it.upload_id);
return u && u.report_date === row.report_date &&
(it.category || 'Unknown') === row.category;
}).length;
expect(row.count).toBe(expected);
}
}),
{ numRuns: 25 },
);
});
});
// =============================================================================
// Test Case 1.D — Property 4: Bug Condition — `/summary` does not silently
// drop sibling uploads.
// =============================================================================
//
// EXPECTED COUNTEREXAMPLE on UNFIXED code:
// With three uploads for 2025-05-11 (NTS_AEO, SDIT_CISO, TSI), the query
// `WHERE vertical IS NULL ORDER BY id DESC LIMIT 1` returns nothing
// (none have vertical=null), so the fallback `WHERE vertical = 'NTS_AEO'`
// selects only the NTS_AEO upload. The other two verticals' summary_json
// is silently dropped — no entries are merged AND no
// `multi_vertical_uploads` field exists on the response.
//
// The fix exposes a `multi_vertical_uploads` array (option (b) per
// design.md Fix 4); option (a) would merge entries from every sibling
// upload. Either of these resolves the bug.
//
// **Validates: Requirements 1.8, 1.9**
//
describe('Bug Condition / Property 4 — GET /summary discloses or merges sibling uploads sharing the latest report_date', () => {
it('1.D canonical fixture — response merges sibling entries OR exposes a non-empty multi_vertical_uploads array of length 2', async () => {
const { uploads, items } = fixtureMultiVerticalSingleDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/summary');
expect(res.statusCode).toBe(200);
// The selected primary upload (NTS_AEO via fallback). Sibling uploads
// are SDIT_CISO and TSI for the same report_date.
const primary = uploads.find(u => u.vertical === 'NTS_AEO');
const expectedSiblings = uploads
.filter(u => u.report_date === primary.report_date && u.id !== primary.id)
.sort((a, b) => a.id - b.id);
const mergedAllEntries =
Array.isArray(res.body.entries) &&
res.body.entries.length === uploads.length;
const disclosedSiblings =
Array.isArray(res.body.multi_vertical_uploads) &&
res.body.multi_vertical_uploads.length === expectedSiblings.length;
// The bug exists iff neither disclosure mechanism is in place.
expect(mergedAllEntries || disclosedSiblings).toBe(true);
// If the response exposes multi_vertical_uploads, validate its shape.
if (disclosedSiblings) {
expect(res.body.multi_vertical_uploads).toHaveLength(2);
const ids = res.body.multi_vertical_uploads.map(s => s.id).sort();
expect(ids).toEqual(expectedSiblings.map(s => s.id).sort());
const verticals = res.body.multi_vertical_uploads.map(s => s.vertical).sort();
expect(verticals).toEqual(expectedSiblings.map(s => s.vertical).sort());
}
});
it('1.D property — when two or more uploads share the latest report_date, the response merges entries OR discloses every sibling', async () => {
await fc.assert(
fc.asyncProperty(arbScenario, async (uploads) => {
// Need at least one upload to have non-null summary_json AND
// be selectable by the existing fallback (vertical IS NULL or
// vertical = 'NTS_AEO'). Skip scenarios that do not exercise
// the /summary code path.
const selectable = uploads.find(u =>
(u.vertical == null || u.vertical === 'NTS_AEO') && u.summary_json,
);
fc.pre(selectable !== undefined);
installReadEndpointHandler(uploads, []);
const res = await request(server, 'GET', '/api/compliance/summary');
expect(res.statusCode).toBe(200);
if (!res.body.upload) {
// No primary upload selected → nothing to disclose.
return;
}
const primaryDate = res.body.upload.report_date;
const siblings = uploads.filter(u =>
u.report_date === primaryDate && u.id !== res.body.upload.id,
);
if (siblings.length === 0) {
// No siblings exist → no disclosure required.
return;
}
const mergedAllEntries =
Array.isArray(res.body.entries) &&
res.body.entries.length >= 1 + siblings.length;
const disclosedSiblings =
Array.isArray(res.body.multi_vertical_uploads) &&
res.body.multi_vertical_uploads.length === siblings.length;
expect(mergedAllEntries || disclosedSiblings).toBe(true);
}),
{ numRuns: 25 },
);
});
});
// =============================================================================
// Test Case 1.E — Property 5: Bug Condition — `persistUpload()` snapshot
// reflects only the snapshotted vertical.
// =============================================================================
//
// EXPECTED COUNTEREXAMPLE on UNFIXED code:
// compliance_items has 100 NTS_AEO hosts on team STEAM and 50 SDIT_CISO
// hosts on team ACCESS-ENG. A new SDIT_CISO upload (one item on STEAM,
// distinct hostname) is persisted. The snapshot query has no vertical
// filter and uses `team AS vertical`, so it produces:
// - vertical = 'STEAM' → total_devices = 101 (100 NTS_AEO + 1 new)
// - vertical = 'ACCESS-ENG' → total_devices = 50 (all SDIT_CISO)
// No row exists for vertical='SDIT_CISO', and the row for STEAM is
// contaminated with 100 NTS_AEO hosts. The fix filters by the upload's
// vertical (`WHERE vertical IS NOT DISTINCT FROM $1`) and groups by
// (vertical, team), producing one snapshot row for SDIT_CISO whose
// total_devices reflects only SDIT_CISO hosts.
//
// **Validates: Requirements 1.10, 1.11**
//
describe('Bug Condition / Property 5 — persistUpload() snapshot is filtered to the snapshotted vertical', () => {
it('1.E canonical fixture — compliance_snapshots row for SDIT_CISO reflects only SDIT_CISO items, not NTS_AEO items', async () => {
const items = fixtureCrossVerticalItems(); // 100 NTS_AEO STEAM + 50 SDIT_CISO ACCESS-ENG
const incomingItem = {
hostname: 'sdit-ciso-new-host-1',
ip_address: '10.0.0.1',
device_type: 'srv',
team: 'STEAM',
metric_id: 'M-NEW',
metric_desc: 'desc',
category: 'Patching',
extra_json: {},
};
const incomingVertical = 'SDIT_CISO';
const snapshotInserts = [];
// The pool.query handler must respond to:
// 1. SELECT id, hostname, metric_id, ... FROM compliance_items WHERE status = 'active'
// 2. The snapshot SELECT (unfixed: no vertical filter; fixed: vertical filter)
// 3. INSERT INTO compliance_snapshots ... ON CONFLICT ... DO UPDATE
// → captured into snapshotInserts
queryHandler = makeQueryHandler([
// (1) Initial active-items load
{
match: /SELECT\s+id,\s+hostname,\s+metric_id,\s+seen_count,\s+first_seen_upload_id\s+FROM\s+compliance_items\s+WHERE\s+status\s*=\s*'active'/i,
rows: () => items.map(it => ({
id: it.id,
hostname: it.hostname,
metric_id: 'M-EXISTING',
seen_count: 1,
first_seen_upload_id: 1,
})),
},
// (2a) UNFIXED snapshot query: SELECT team AS vertical ... FROM compliance_items WHERE team IS NOT NULL GROUP BY team
{
match: /SELECT\s+team\s+AS\s+vertical[\s\S]*FROM\s+compliance_items[\s\S]*WHERE\s+team\s+IS\s+NOT\s+NULL[\s\S]*GROUP\s+BY\s+team/i,
rows: () => {
// Aggregate ALL items by team (regardless of vertical) — bug condition.
const byTeam = {};
const allHosts = items.concat([{
hostname: incomingItem.hostname,
team: incomingItem.team,
vertical: incomingVertical,
status: 'active',
}]);
for (const it of allHosts) {
if (!it.team) continue;
if (!byTeam[it.team]) {
byTeam[it.team] = { vertical: it.team, hosts: new Set(), compliant: new Set(), nonCompliant: new Set() };
}
byTeam[it.team].hosts.add(it.hostname);
if (it.status === 'resolved') byTeam[it.team].compliant.add(it.hostname);
if (it.status === 'active') byTeam[it.team].nonCompliant.add(it.hostname);
}
return Object.values(byTeam).map(b => ({
vertical: b.vertical,
total_devices: b.hosts.size,
compliant: b.compliant.size,
non_compliant: b.nonCompliant.size,
}));
},
},
// (2b) FIXED snapshot query: ... WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1 GROUP BY vertical, team
{
match: /WHERE\s+team\s+IS\s+NOT\s+NULL\s+AND\s+vertical\s+IS\s+NOT\s+DISTINCT\s+FROM/i,
rows: (_text, params) => {
const filterVertical = (params || [])[0];
const allHosts = items.concat([{
hostname: incomingItem.hostname,
team: incomingItem.team,
vertical: incomingVertical,
status: 'active',
}]);
const filtered = allHosts.filter(it => {
if (filterVertical == null) return it.vertical == null;
return it.vertical === filterVertical;
});
const byKey = {};
for (const it of filtered) {
if (!it.team) continue;
const k = `${it.vertical}|${it.team}`;
if (!byKey[k]) {
byKey[k] = {
vertical: it.vertical,
team: it.team,
hosts: new Set(),
compliant: new Set(),
nonCompliant: new Set(),
};
}
byKey[k].hosts.add(it.hostname);
if (it.status === 'resolved') byKey[k].compliant.add(it.hostname);
if (it.status === 'active') byKey[k].nonCompliant.add(it.hostname);
}
return Object.values(byKey).map(b => ({
vertical: b.vertical,
team: b.team,
total_devices: b.hosts.size,
compliant: b.compliant.size,
non_compliant: b.nonCompliant.size,
}));
},
},
// (3) Snapshot upsert — capture every insert
{
match: /INSERT\s+INTO\s+compliance_snapshots/i,
rows: (_text, params) => {
const [snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct] = params || [];
snapshotInserts.push({ snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct });
return [];
},
},
]);
// The transactional client is also routed through queryHandler; it
// must answer the within-transaction queries (BEGIN/COMMIT, INSERT
// INTO compliance_uploads RETURNING id, item upserts, the resolved
// updates, and the final upload counts UPDATE).
const client = {
query: jest.fn((text, params) => {
if (/^\s*BEGIN/i.test(text) || /^\s*COMMIT/i.test(text) || /^\s*ROLLBACK/i.test(text)) {
return Promise.resolve({ rows: [], rowCount: 0 });
}
if (/INSERT\s+INTO\s+compliance_uploads[\s\S]*RETURNING\s+id/i.test(text)) {
return Promise.resolve({ rows: [{ id: 9999 }], rowCount: 1 });
}
// All other within-transaction writes succeed silently.
return Promise.resolve({ rows: [], rowCount: 1 });
}),
release: jest.fn(),
};
mockPool.connect.mockResolvedValueOnce(client);
await persistUpload({
items: [incomingItem],
summary: { entries: [], overall_scores: {} },
reportDate: '2025-05-11',
filename: 'sdit-ciso-2025-05-11.xlsx',
userId: 1,
vertical: incomingVertical,
});
// (1) A snapshot row was written for vertical = 'SDIT_CISO'.
const sditSnapshots = snapshotInserts.filter(s => s.vertical === 'SDIT_CISO');
expect(sditSnapshots.length).toBeGreaterThan(0);
// (2) Its total_devices reflects only SDIT_CISO items.
// Pre-existing SDIT_CISO hosts: 50 (on ACCESS-ENG team).
// Plus the one incoming SDIT_CISO host on STEAM.
// If the snapshot is grouped per (vertical, team), we expect
// either one row totalling 51 hosts or two rows that together
// total 51. Either way, total_devices for SDIT_CISO must NOT
// equal 151 (the inflated cross-vertical figure that includes
// the 100 NTS_AEO hosts).
const sditTotal = sditSnapshots.reduce((s, r) => s + r.total_devices, 0);
const ntsAeoHostCount = items.filter(it => it.vertical === 'NTS_AEO').length;
expect(sditTotal).toBeLessThan(ntsAeoHostCount + 1);
expect(sditTotal).toBe(50 + 1);
// (3) No snapshot row inflates total_devices with NTS_AEO hosts.
// The unfixed code emits vertical='STEAM' with total=101
// (100 NTS_AEO + 1 new SDIT_CISO host). The fix emits per-vertical
// rows, so any STEAM row must reflect only items whose vertical
// is the snapshotted vertical.
const steamSnapshots = snapshotInserts.filter(s => s.vertical === 'STEAM');
for (const s of steamSnapshots) {
// Under the fix, a snapshot row keyed on vertical='STEAM' should
// not exist at all (the upload's vertical is SDIT_CISO). Even if
// legacy code paths still write a STEAM row, it must not include
// the 100 NTS_AEO hosts as a single combined total.
expect(s.total_devices).toBeLessThan(ntsAeoHostCount);
}
});
});
// =============================================================================
// PRESERVATION TESTS (Task 2 — Property 2)
// =============================================================================
//
// These tests pin the BASELINE behavior of the unfixed code on inputs where
// the bug condition does NOT hold (single-upload-per-date scenarios, empty
// state, error paths, and unrelated query-parameter filtering). They MUST
// pass on the unfixed code; they will continue to pass after the five fixes
// land in tasks 3 through 7. Any future change that alters these responses
// in the non-bug-condition input space is a regression.
//
// Bug Condition negation (from design.md):
// FORALL report_date d, COUNT(compliance_uploads WHERE report_date = d) <= 1
//
// **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10**
//
// --- Preservation fixtures (per design.md "Test Fixtures Required") ---
/**
* fixture_empty no compliance_uploads, no compliance_items.
* Used by 2.A.
*/
function fixtureEmpty() {
return { uploads: [], items: [] };
}
/**
* fixture_single_upload_aeo_legacy one legacy AEO upload (vertical IS NULL)
* dated 2025-04-01 with 20 items distributed across the four ALLOWED_TEAMS.
* Items are evenly tagged across two categories (Patching, Configuration).
* Used by 2.B.
*/
function fixtureSingleUploadAeoLegacy() {
const upload = {
id: 100,
report_date: '2025-04-01',
vertical: null,
new_count: 12,
recurring_count: 8,
resolved_count: 3,
uploaded_at: '2025-04-01T09:00:00Z',
summary_json: JSON.stringify({
entries: [
{ team: 'STEAM', metric: 'patching', score: 82 },
{ team: 'ACCESS-ENG', metric: 'patching', score: 76 },
{ team: 'ACCESS-OPS', metric: 'configuration', score: 88 },
{ team: 'INTELDEV', metric: 'configuration', score: 91 },
],
overall_scores: { patching: 79, configuration: 90 },
}),
};
const teamLayout = ['STEAM', 'STEAM', 'STEAM', 'STEAM', 'STEAM',
'ACCESS-ENG', 'ACCESS-ENG', 'ACCESS-ENG', 'ACCESS-ENG', 'ACCESS-ENG',
'ACCESS-OPS', 'ACCESS-OPS', 'ACCESS-OPS', 'ACCESS-OPS', 'ACCESS-OPS',
'INTELDEV', 'INTELDEV', 'INTELDEV', 'INTELDEV', 'INTELDEV'];
const items = teamLayout.map((team, i) => ({
id: 4000 + i,
upload_id: upload.id,
hostname: `aeo-host-${i + 1}`,
team,
category: i % 2 === 0 ? 'Patching' : 'Configuration',
vertical: null,
status: 'active',
}));
return { uploads: [upload], items };
}
/**
* fixture_single_upload_per_date five uploads on five distinct dates with
* varied vertical values, satisfying the bug-condition negation
* (every report_date has exactly one upload row). 46 items per upload.
* Used by 2.C.
*/
function fixtureSingleUploadPerDate() {
const spec = [
{ id: 200, date: '2025-04-01', vertical: null, new: 5, rec: 3, res: 1 },
{ id: 201, date: '2025-04-08', vertical: 'NTS_AEO', new: 7, rec: 2, res: 4 },
{ id: 202, date: '2025-04-15', vertical: 'SDIT_CISO', new: 4, rec: 6, res: 2 },
{ id: 203, date: '2025-04-22', vertical: 'TSI', new: 9, rec: 1, res: 0 },
{ id: 204, date: '2025-05-01', vertical: null, new: 6, rec: 4, res: 5 },
];
const uploads = spec.map(s => ({
id: s.id,
report_date: s.date,
vertical: s.vertical,
new_count: s.new,
recurring_count: s.rec,
resolved_count: s.res,
uploaded_at: `${s.date}T08:00:00Z`,
summary_json: JSON.stringify({
entries: [{ team: 'STEAM', metric: 'patching', score: 80 + (s.id % 10) }],
overall_scores: { patching: 80 + (s.id % 10) },
}),
}));
const items = [];
let itemId = 6000;
for (const u of uploads) {
const layout = [
{ team: 'STEAM', category: 'Patching' },
{ team: 'STEAM', category: 'Configuration' },
{ team: 'ACCESS-ENG', category: 'Patching' },
{ team: 'ACCESS-OPS', category: 'Configuration' },
{ team: 'INTELDEV', category: 'Patching' },
];
for (const l of layout) {
items.push({
id: itemId++,
upload_id: u.id,
hostname: `${u.vertical || 'AEO'}-host-${itemId}`,
team: l.team,
category: l.category,
vertical: u.vertical,
status: 'active',
});
}
}
return { uploads, items };
}
/**
* fixture_cross_vertical_items_single only NTS_AEO items present in
* compliance_items (no SDIT_CISO/TSI items). Used by 2.E to exercise
* the persistUpload() single-vertical-month preservation path.
*/
function fixtureSingleVerticalItems() {
const items = [];
let id = 7000;
for (let i = 1; i <= 30; i++) {
items.push({
id: id++,
hostname: `nts-aeo-only-host-${i}`,
team: i % 2 === 0 ? 'STEAM' : 'ACCESS-ENG',
vertical: 'NTS_AEO',
status: i % 5 === 0 ? 'resolved' : 'active',
});
}
return items;
}
// --- arbScenario_singleUploadPerDate — fast-check generator restricted to
// scenarios where every report_date has exactly one upload row. ---
const arbUniqueDate = fc.constantFrom(
'2025-03-04', '2025-03-11', '2025-03-18', '2025-03-25',
'2025-04-01', '2025-04-08', '2025-04-15', '2025-04-22',
'2025-05-01', '2025-05-18', '2025-05-25',
);
const arbUploadUnique = fc.record({
report_date: arbUniqueDate,
vertical: arbVertical,
new_count: fc.integer({ min: 0, max: 30 }),
recurring_count: fc.integer({ min: 0, max: 30 }),
resolved_count: fc.integer({ min: 0, max: 30 }),
});
/**
* arbScenarioSingleUploadPerDate list of compliance_uploads rows where
* every report_date appears at most once. Enforced post-generation via a
* filter; fast-check shrinking still finds simple counterexamples.
*/
const arbScenarioSingleUploadPerDate = fc.array(arbUploadUnique, { minLength: 0, maxLength: 6 })
.filter(arr => {
const dates = arr.map(u => u.report_date);
return new Set(dates).size === dates.length;
})
.map((rawUploads) => rawUploads.map((u, i) => ({
id: 8000 + i,
uploaded_at: `${u.report_date}T${10 + i}:00:00Z`,
summary_json: JSON.stringify({
entries: [{ team: 'STEAM', metric: 'patching', score: 80 }],
overall_scores: { patching: 80 },
}),
...u,
})));
// =============================================================================
// Test Case 2.A — Empty-state preservation
// =============================================================================
//
// With no compliance_uploads and no compliance_items, every read endpoint
// SHALL return its documented empty-state shape unchanged.
//
// **Validates: Requirements 3.3, 3.10**
//
describe('Preservation 2.A — empty-state response shapes are unchanged', () => {
it('GET /trends returns { trends: [] } when no uploads exist', async () => {
const { uploads, items } = fixtureEmpty();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/trends');
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ trends: [] });
});
it('GET /top-recurring returns { waterfall: [] } when no uploads exist', async () => {
const { uploads, items } = fixtureEmpty();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/top-recurring');
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ waterfall: [] });
});
it('GET /category-trend returns { categoryTrend: [] } when no uploads exist', async () => {
const { uploads, items } = fixtureEmpty();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/category-trend');
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ categoryTrend: [] });
});
it('GET /summary returns { entries: [], overall_scores: {}, upload: null } when no uploads exist', async () => {
const { uploads, items } = fixtureEmpty();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/summary');
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ entries: [], overall_scores: {}, upload: null });
});
});
// =============================================================================
// Test Case 2.B — Single AEO-legacy-upload preservation
// =============================================================================
//
// One legacy upload with vertical IS NULL. The four read endpoints SHALL
// produce responses that match the captured baseline byte-for-byte.
// This is the "snapshot equality — single AEO-only upload" case from
// design.md "Preservation Checking → Test Cases".
//
// **Validates: Requirements 3.1, 3.2, 3.4, 3.5, 3.6**
//
describe('Preservation 2.B — single AEO-legacy upload responses are byte-for-byte stable', () => {
it('GET /trends returns one entry for the legacy upload with full per-team breakdown', async () => {
const { uploads, items } = fixtureSingleUploadAeoLegacy();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/trends');
expect(res.statusCode).toBe(200);
// The legacy fixture has 5 items per team across 4 teams = 20 items.
// Per-team breakdown is captured exactly so any future change that
// alters this aggregation surfaces as a regression.
expect(res.body).toEqual({
trends: [
{
report_date: '2025-04-01',
new_count: 12,
recurring_count: 8,
resolved_count: 3,
total_active: 20,
STEAM: 5,
'ACCESS-ENG': 5,
'ACCESS-OPS': 5,
INTELDEV: 5,
},
],
});
});
it('GET /top-recurring returns a single waterfall entry with start=0 and correct end', async () => {
const { uploads, items } = fixtureSingleUploadAeoLegacy();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/top-recurring');
expect(res.statusCode).toBe(200);
// For a single legacy upload, the running-total invariant collapses
// to start=0, end = new + recurring - resolved = 12 + 8 - 3 = 17.
expect(res.body).toEqual({
waterfall: [
{
date: '2025-04-01',
start: 0,
new_count: 12,
recurring_count: 8,
resolved_count: 3,
end: 17,
},
],
});
});
it('GET /category-trend returns one row per category with the legacy upload counts', async () => {
const { uploads, items } = fixtureSingleUploadAeoLegacy();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/category-trend');
expect(res.statusCode).toBe(200);
// 20 items total split across two categories: items at even indexes
// are 'Patching' (10 items), odd indexes are 'Configuration' (10 items).
expect(res.body).toEqual({
categoryTrend: [
{ report_date: '2025-04-01', category: 'Configuration', count: 10 },
{ report_date: '2025-04-01', category: 'Patching', count: 10 },
],
});
});
it('GET /summary returns the legacy upload`s entries and overall_scores via the vertical IS NULL fallback', async () => {
const { uploads, items } = fixtureSingleUploadAeoLegacy();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/summary');
expect(res.statusCode).toBe(200);
// The vertical IS NULL → vertical = 'NTS_AEO' fallback selects the
// legacy upload directly (vertical IS NULL succeeds first). The
// response surfaces summary.entries and summary.overall_scores
// unchanged plus a stub upload reference.
expect(res.body).toEqual({
entries: [
{ team: 'STEAM', metric: 'patching', score: 82 },
{ team: 'ACCESS-ENG', metric: 'patching', score: 76 },
{ team: 'ACCESS-OPS', metric: 'configuration', score: 88 },
{ team: 'INTELDEV', metric: 'configuration', score: 91 },
],
overall_scores: { patching: 79, configuration: 90 },
upload: {
id: 100,
report_date: '2025-04-01',
uploaded_at: '2025-04-01T09:00:00Z',
},
});
});
});
// =============================================================================
// Test Case 2.C — Multiple single-upload-per-date preservation
// =============================================================================
//
// Five distinct dates, one upload per date, varied vertical values. Every
// read endpoint SHALL produce results identical to the captured baseline.
// Bug condition negation holds (no two uploads share a report_date).
//
// **Validates: Requirements 3.1, 3.4, 3.5**
//
describe('Preservation 2.C — multiple single-upload-per-date responses are byte-for-byte stable', () => {
it('GET /trends returns one entry per date, ordered by report_date ASC, with correct per-team breakdowns', async () => {
const { uploads, items } = fixtureSingleUploadPerDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/trends');
expect(res.statusCode).toBe(200);
// Each upload has 5 items in the layout (2 STEAM, 1 ACCESS-ENG,
// 1 ACCESS-OPS, 1 INTELDEV). With one upload per date, every date
// produces an identical per-team breakdown.
const expectedTrends = uploads.map(u => ({
report_date: u.report_date,
new_count: u.new_count,
recurring_count: u.recurring_count,
resolved_count: u.resolved_count,
total_active: u.new_count + u.recurring_count,
STEAM: 2,
'ACCESS-ENG': 1,
'ACCESS-OPS': 1,
INTELDEV: 1,
}));
expect(res.body).toEqual({ trends: expectedTrends });
});
it('GET /top-recurring emits one waterfall entry per date with start carrying forward from previous end', async () => {
const { uploads, items } = fixtureSingleUploadPerDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/top-recurring');
expect(res.statusCode).toBe(200);
// Build the expected waterfall by walking uploads in date order and
// carrying `start` forward. Single-upload-per-date is the canonical
// case computeWaterfall() was designed for; the running-total
// semantics MUST match the pre-fix output exactly.
let start = 0;
const expectedWaterfall = uploads.map(u => {
const end = start + u.new_count + u.recurring_count - u.resolved_count;
const entry = {
date: u.report_date,
start,
new_count: u.new_count,
recurring_count: u.recurring_count,
resolved_count: u.resolved_count,
end,
};
start = end;
return entry;
});
expect(res.body).toEqual({ waterfall: expectedWaterfall });
});
it('GET /category-trend returns one row per (date, category), ordered by date then category', async () => {
const { uploads, items } = fixtureSingleUploadPerDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/category-trend');
expect(res.statusCode).toBe(200);
// Each upload has 3 items in 'Patching' (indexes 0, 2, 4) and
// 2 items in 'Configuration' (indexes 1, 3). Single-upload-per-date
// means each (date, category) pair appears exactly once.
const expected = [];
for (const u of uploads) {
expected.push({ report_date: u.report_date, category: 'Configuration', count: 2 });
expected.push({ report_date: u.report_date, category: 'Patching', count: 3 });
}
expect(res.body).toEqual({ categoryTrend: expected });
});
it('GET /summary surfaces the latest legacy/NTS_AEO upload via the existing fallback', async () => {
const { uploads, items } = fixtureSingleUploadPerDate();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/summary');
expect(res.statusCode).toBe(200);
// The fallback prefers vertical IS NULL with the highest id. In this
// fixture, the highest-id upload with vertical IS NULL is id=204
// (date 2025-05-01).
const primary = uploads.find(u => u.id === 204);
expect(res.body).toEqual({
entries: [{ team: 'STEAM', metric: 'patching', score: 80 + (204 % 10) }],
overall_scores: { patching: 80 + (204 % 10) },
upload: {
id: primary.id,
report_date: primary.report_date,
uploaded_at: primary.uploaded_at,
},
});
});
});
// =============================================================================
// Test Case 2.D — /summary `team` query parameter preservation
// =============================================================================
//
// The `team` query parameter still filters `entries` server-side and still
// rejects non-ALLOWED_TEAMS values with HTTP 400.
//
// **Validates: Requirement 3.7**
//
describe('Preservation 2.D — GET /summary `team` query parameter is unchanged', () => {
it('?team=STEAM filters entries to STEAM only', async () => {
const { uploads, items } = fixtureSingleUploadAeoLegacy();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/summary?team=STEAM');
expect(res.statusCode).toBe(200);
expect(res.body.entries).toEqual([
{ team: 'STEAM', metric: 'patching', score: 82 },
]);
// The unrelated overall_scores and upload fields remain unchanged
// when filtering by team.
expect(res.body.overall_scores).toEqual({ patching: 79, configuration: 90 });
expect(res.body.upload).toEqual({
id: 100,
report_date: '2025-04-01',
uploaded_at: '2025-04-01T09:00:00Z',
});
});
it('?team=OTHER (not in ALLOWED_TEAMS) returns HTTP 400 with { error }', async () => {
const { uploads, items } = fixtureSingleUploadAeoLegacy();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/summary?team=OTHER');
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Invalid team' });
});
it('?team=ACCESS-ENG filters entries to ACCESS-ENG only', async () => {
const { uploads, items } = fixtureSingleUploadAeoLegacy();
installReadEndpointHandler(uploads, items);
const res = await request(server, 'GET', '/api/compliance/summary?team=ACCESS-ENG');
expect(res.statusCode).toBe(200);
expect(res.body.entries).toEqual([
{ team: 'ACCESS-ENG', metric: 'patching', score: 76 },
]);
});
});
// =============================================================================
// Test Case 2.E — persistUpload() single-vertical-month preservation
// =============================================================================
//
// When compliance_items contains rows from only one vertical, the snapshot
// rows written by persistUpload() SHALL be identical to the pre-fix output.
// This is the single-vertical-month equivalent of the fixed-code expectation
// that snapshots reflect only the snapshotted vertical.
//
// **Validates: Requirement 3.8**
//
describe('Preservation 2.E — persistUpload() snapshot is unchanged for single-vertical months', () => {
it('snapshot rows reflect the single-vertical compliance_items state with no cross-contamination', async () => {
const items = fixtureSingleVerticalItems(); // only NTS_AEO items
const incomingItem = {
hostname: 'nts-aeo-new-host-1',
ip_address: '10.0.0.1',
device_type: 'srv',
team: 'STEAM',
metric_id: 'M-NEW',
metric_desc: 'desc',
category: 'Patching',
extra_json: {},
};
const incomingVertical = 'NTS_AEO';
const snapshotInserts = [];
queryHandler = makeQueryHandler([
// (1) Initial active-items load
{
match: /SELECT\s+id,\s+hostname,\s+metric_id,\s+seen_count,\s+first_seen_upload_id\s+FROM\s+compliance_items\s+WHERE\s+status\s*=\s*'active'/i,
rows: () => items
.filter(it => it.status === 'active')
.map(it => ({
id: it.id,
hostname: it.hostname,
metric_id: 'M-EXISTING',
seen_count: 1,
first_seen_upload_id: 1,
})),
},
// (2a) UNFIXED snapshot query: SELECT team AS vertical ... GROUP BY team
{
match: /SELECT\s+team\s+AS\s+vertical[\s\S]*FROM\s+compliance_items[\s\S]*WHERE\s+team\s+IS\s+NOT\s+NULL[\s\S]*GROUP\s+BY\s+team/i,
rows: () => {
// Single-vertical month: aggregating ALL items by team
// gives the same per-vertical answer as filtering by
// the snapshotted vertical, because no other verticals
// contribute. This is the preservation case.
const allHosts = items.concat([{
hostname: incomingItem.hostname,
team: incomingItem.team,
vertical: incomingVertical,
status: 'active',
}]);
const byTeam = {};
for (const it of allHosts) {
if (!it.team) continue;
if (!byTeam[it.team]) {
byTeam[it.team] = { vertical: it.team, hosts: new Set(), compliant: new Set(), nonCompliant: new Set() };
}
byTeam[it.team].hosts.add(it.hostname);
if (it.status === 'resolved') byTeam[it.team].compliant.add(it.hostname);
if (it.status === 'active') byTeam[it.team].nonCompliant.add(it.hostname);
}
return Object.values(byTeam).map(b => ({
vertical: b.vertical,
total_devices: b.hosts.size,
compliant: b.compliant.size,
non_compliant: b.nonCompliant.size,
}));
},
},
// (2b) FIXED snapshot query: ... WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1 GROUP BY vertical, team
//
// The /commit route calls persistUpload() without a vertical
// argument (legacy AEO uploads default to vertical = null), so
// the fixed SQL filters items via `vertical IS NOT DISTINCT FROM
// null`. For a single-vertical month, this is equivalent to
// aggregating by team alone — every contributing item shares the
// same (null) vertical bucket from the upload's perspective.
//
// Returning rows with `vertical: null, team: <team>` makes the
// production INSERT loop's `vs.vertical || vs.team` fallback
// resolve to the team name, which preserves the historical
// team-as-vertical snapshot key for legacy AEO upload contexts.
{
match: /WHERE\s+team\s+IS\s+NOT\s+NULL\s+AND\s+vertical\s+IS\s+NOT\s+DISTINCT\s+FROM/i,
rows: () => {
const allHosts = items.concat([{
hostname: incomingItem.hostname,
team: incomingItem.team,
vertical: incomingVertical,
status: 'active',
}]);
const byTeam = {};
for (const it of allHosts) {
if (!it.team) continue;
if (!byTeam[it.team]) {
byTeam[it.team] = {
team: it.team,
hosts: new Set(),
compliant: new Set(),
nonCompliant: new Set(),
};
}
byTeam[it.team].hosts.add(it.hostname);
if (it.status === 'resolved') byTeam[it.team].compliant.add(it.hostname);
if (it.status === 'active') byTeam[it.team].nonCompliant.add(it.hostname);
}
return Object.values(byTeam).map(b => ({
vertical: null,
team: b.team,
total_devices: b.hosts.size,
compliant: b.compliant.size,
non_compliant: b.nonCompliant.size,
}));
},
},
// (3) Snapshot upsert — capture every insert
{
match: /INSERT\s+INTO\s+compliance_snapshots/i,
rows: (_text, params) => {
const [snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct] = params || [];
snapshotInserts.push({ snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct });
return [];
},
},
]);
const client = {
query: jest.fn((text, _params) => {
if (/^\s*BEGIN/i.test(text) || /^\s*COMMIT/i.test(text) || /^\s*ROLLBACK/i.test(text)) {
return Promise.resolve({ rows: [], rowCount: 0 });
}
if (/INSERT\s+INTO\s+compliance_uploads[\s\S]*RETURNING\s+id/i.test(text)) {
return Promise.resolve({ rows: [{ id: 9999 }], rowCount: 1 });
}
return Promise.resolve({ rows: [], rowCount: 1 });
}),
release: jest.fn(),
};
mockPool.connect.mockResolvedValueOnce(client);
const result = await persistUpload({
items: [incomingItem],
summary: { entries: [], overall_scores: {} },
reportDate: '2025-04-01',
filename: 'nts-aeo-only-2025-04-01.xlsx',
userId: 1,
});
// (1) Upload commit succeeded with the expected uploadId.
expect(result.uploadId).toBe(9999);
// (2) Snapshot rows were written. With only NTS_AEO items present,
// the per-team aggregation gives:
// - team='STEAM' → all 15 NTS_AEO STEAM hosts (even ids
// in the fixture, indexes 2..30 = 15 hosts) plus the new
// incoming STEAM host = 16 total.
// - team='ACCESS-ENG' → 15 NTS_AEO ACCESS-ENG hosts.
const steamSnap = snapshotInserts.find(s => s.vertical === 'STEAM');
const accessSnap = snapshotInserts.find(s => s.vertical === 'ACCESS-ENG');
expect(steamSnap).toBeDefined();
expect(accessSnap).toBeDefined();
// STEAM bucket: NTS_AEO indexes 2,4,...,30 = 15 hosts + the new one.
expect(steamSnap.total_devices).toBe(16);
// ACCESS-ENG bucket: NTS_AEO indexes 1,3,...,29 = 15 hosts.
expect(accessSnap.total_devices).toBe(15);
// (3) compliance_pct is consistent with the captured baseline:
// resolved hosts are at indexes divisible by 5 (5,10,15,20,25,30).
// STEAM: 10, 20, 30 → 3 resolved out of 16 → 18.75%.
// ACCESS-ENG: 5, 15, 25 → 3 resolved out of 15 → 20%.
expect(steamSnap.compliant).toBe(3);
expect(steamSnap.non_compliant).toBe(13); // 16 - 3 resolved = 13 active
expect(steamSnap.compliance_pct).toBe(18.75);
expect(accessSnap.compliant).toBe(3);
expect(accessSnap.non_compliant).toBe(12);
expect(accessSnap.compliance_pct).toBe(20);
});
});
// =============================================================================
// Test Case 2.F — persistUpload() snapshot error-path preservation
// =============================================================================
//
// When the snapshot SELECT query fails, persistUpload() SHALL still commit
// the upload, log the error to console.error, and not surface the error to
// the caller. The HTTP layer SHALL NOT respond with an error status.
//
// **Validates: Requirement 3.9**
//
describe('Preservation 2.F — persistUpload() commits the upload when snapshot creation fails', () => {
it('snapshot query rejection is caught — upload commits, error is logged, no error is thrown to caller', async () => {
const incomingItem = {
hostname: 'preserve-host-1',
ip_address: '10.0.0.99',
device_type: 'srv',
team: 'STEAM',
metric_id: 'M-PRESERVE',
metric_desc: 'desc',
category: 'Patching',
extra_json: {},
};
const snapshotInserts = [];
queryHandler = makeQueryHandler([
// (1) Initial active-items load — empty
{
match: /SELECT\s+id,\s+hostname,\s+metric_id,\s+seen_count,\s+first_seen_upload_id\s+FROM\s+compliance_items\s+WHERE\s+status\s*=\s*'active'/i,
rows: () => [],
},
// (2) Snapshot SELECT — REJECT to force the error path. We use
// a sentinel marker via a function-rows handler that throws.
// The makeQueryHandler infrastructure expects a row array, so
// we drop in a row provider that returns a Promise rejection
// by overriding the handler below.
]);
// Wrap the existing handler so the snapshot SELECT rejects.
const baseHandler = queryHandler;
queryHandler = (text, params) => {
if (/SELECT\s+team\s+AS\s+vertical[\s\S]*FROM\s+compliance_items[\s\S]*WHERE\s+team\s+IS\s+NOT\s+NULL[\s\S]*GROUP\s+BY\s+team/i.test(text)
|| /WHERE\s+team\s+IS\s+NOT\s+NULL\s+AND\s+vertical\s+IS\s+NOT\s+DISTINCT\s+FROM/i.test(text)) {
return Promise.reject(new Error('simulated snapshot query failure'));
}
if (/INSERT\s+INTO\s+compliance_snapshots/i.test(text)) {
const [snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct] = params || [];
snapshotInserts.push({ snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct });
return Promise.resolve({ rows: [], rowCount: 0 });
}
return baseHandler(text, params);
};
const client = {
query: jest.fn((text, _params) => {
if (/^\s*BEGIN/i.test(text) || /^\s*COMMIT/i.test(text) || /^\s*ROLLBACK/i.test(text)) {
return Promise.resolve({ rows: [], rowCount: 0 });
}
if (/INSERT\s+INTO\s+compliance_uploads[\s\S]*RETURNING\s+id/i.test(text)) {
return Promise.resolve({ rows: [{ id: 4242 }], rowCount: 1 });
}
return Promise.resolve({ rows: [], rowCount: 1 });
}),
release: jest.fn(),
};
mockPool.connect.mockResolvedValueOnce(client);
// Silence the expected console.error so the test output stays clean.
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
let result;
let thrown;
try {
result = await persistUpload({
items: [incomingItem],
summary: { entries: [], overall_scores: {} },
reportDate: '2025-04-01',
filename: 'preserve-error-2025-04-01.xlsx',
userId: 1,
});
} catch (err) {
thrown = err;
}
// (1) persistUpload() did NOT throw — the snapshot error is swallowed.
expect(thrown).toBeUndefined();
// (2) Upload commit returned the expected payload.
expect(result).toBeDefined();
expect(result.uploadId).toBe(4242);
expect(result.newCount).toBe(1);
expect(result.recurringCount).toBe(0);
expect(result.resolvedCount).toBe(0);
// (3) The transaction reached COMMIT (not ROLLBACK) before the
// snapshot block ran. Inspect the client.query mock for COMMIT.
const commitCalls = client.query.mock.calls.filter(c => /^\s*COMMIT/i.test(c[0]));
const rollbackCalls = client.query.mock.calls.filter(c => /^\s*ROLLBACK/i.test(c[0]));
expect(commitCalls.length).toBe(1);
expect(rollbackCalls.length).toBe(0);
// (4) The error WAS logged (preserves the existing error-path
// observability contract).
expect(errSpy).toHaveBeenCalled();
const loggedFirstArg = errSpy.mock.calls.map(c => String(c[0])).join('|');
expect(loggedFirstArg).toContain('[Compliance] Snapshot creation error:');
// (5) No INSERT INTO compliance_snapshots fired because the SELECT
// rejected before the upsert loop.
expect(snapshotInserts).toHaveLength(0);
errSpy.mockRestore();
});
});
// =============================================================================
// Cross-endpoint preservation property — fast-check extension of Task 2
// =============================================================================
//
// For any randomly generated scenario where every report_date has exactly
// one upload row (the bug-condition negation), the four read endpoints
// SHALL satisfy the same shape and equality predicates that the captured
// baseline establishes for the canonical fixtures: one trend per date,
// one waterfall entry per date with the running invariant, one
// (date, category) row per pair, and a /summary with a non-null `upload`
// when any selectable upload exists.
//
// **Validates: Requirements 3.1, 3.2, 3.4, 3.5**
//
describe('Preservation cross-endpoint property — single-upload-per-date scenarios match the baseline shape', () => {
it('GET /trends produces one entry per report_date, in date-ascending order, with counts equal to the per-date upload values', async () => {
await fc.assert(
fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => {
installReadEndpointHandler(uploads, []);
const res = await request(server, 'GET', '/api/compliance/trends');
expect(res.statusCode).toBe(200);
expect(res.body.trends).toHaveLength(uploads.length);
// Order: report_date ascending.
const sorted = [...uploads].sort((a, b) =>
a.report_date.localeCompare(b.report_date),
);
for (let i = 0; i < sorted.length; i++) {
const u = sorted[i];
const t = res.body.trends[i];
expect(t.report_date).toBe(u.report_date);
expect(t.new_count).toBe(u.new_count);
expect(t.recurring_count).toBe(u.recurring_count);
expect(t.resolved_count).toBe(u.resolved_count);
expect(t.total_active).toBe(u.new_count + u.recurring_count);
// No items in this scenario → all per-team counts are 0.
expect(t.STEAM).toBe(0);
expect(t['ACCESS-ENG']).toBe(0);
expect(t['ACCESS-OPS']).toBe(0);
expect(t.INTELDEV).toBe(0);
}
}),
{ numRuns: 25 },
);
});
it('GET /top-recurring produces one waterfall entry per report_date with the running invariant carried forward', async () => {
await fc.assert(
fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => {
installReadEndpointHandler(uploads, []);
const res = await request(server, 'GET', '/api/compliance/top-recurring');
expect(res.statusCode).toBe(200);
const wf = res.body.waterfall;
expect(wf).toHaveLength(uploads.length);
if (wf.length > 0) {
expect(wf[0].start).toBe(0);
}
for (let i = 0; i < wf.length; i++) {
expect(wf[i].end).toBe(
wf[i].start + wf[i].new_count + wf[i].recurring_count - wf[i].resolved_count,
);
if (i > 0) {
expect(wf[i].start).toBe(wf[i - 1].end);
}
}
}),
{ numRuns: 25 },
);
});
it('GET /category-trend has at most one row per (date, category) pair', async () => {
await fc.assert(
fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => {
installReadEndpointHandler(uploads, []);
const res = await request(server, 'GET', '/api/compliance/category-trend');
expect(res.statusCode).toBe(200);
const seen = new Set();
for (const row of res.body.categoryTrend) {
const key = `${row.report_date}|${row.category}`;
expect(seen.has(key)).toBe(false);
seen.add(key);
}
}),
{ numRuns: 25 },
);
});
it('GET /summary returns null upload when no selectable upload exists, otherwise discloses no siblings', async () => {
await fc.assert(
fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => {
installReadEndpointHandler(uploads, []);
const res = await request(server, 'GET', '/api/compliance/summary');
expect(res.statusCode).toBe(200);
const selectable = uploads.filter(u =>
(u.vertical == null || u.vertical === 'NTS_AEO') && u.summary_json,
);
if (selectable.length === 0) {
expect(res.body).toEqual({ entries: [], overall_scores: {}, upload: null });
return;
}
// A primary upload exists. Bug-condition negation holds, so
// no sibling upload shares its report_date.
expect(res.body.upload).not.toBeNull();
const primaryDate = res.body.upload.report_date;
const siblings = uploads.filter(u =>
u.report_date === primaryDate && u.id !== res.body.upload.id,
);
expect(siblings).toHaveLength(0);
}),
{ numRuns: 25 },
);
});
});