Fix Clear Completed button failing on queue items with Jira ticket links
The DELETE /completed endpoint failed with a FK violation when completed queue items had associated rows in jira_ticket_queue_items. Replaced the bare DELETE query with a transaction that removes junction table references before deleting the queue items themselves. Transaction sequence: BEGIN → SELECT completed IDs → DELETE junction rows → DELETE queue items → COMMIT, with ROLLBACK on error and client release in finally block.
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Bug Condition Exploration Property Test: Ivanti Queue Clear Completed FK Violation
|
||||
*
|
||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
||||
*
|
||||
* BUG CONDITION (from design.md):
|
||||
* isBugCondition(input) returns true when linkedItems.length > 0
|
||||
* — completed queue items have associated rows in jira_ticket_queue_items
|
||||
*
|
||||
* The current DELETE /completed handler issues a bare:
|
||||
* DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'
|
||||
* which fails with a FK violation when child rows exist in jira_ticket_queue_items.
|
||||
*
|
||||
* THIS TEST ENCODES THE EXPECTED (FIXED) BEHAVIOR:
|
||||
* The handler should delete junction table references first, then delete
|
||||
* completed queue items, all within a transaction, and return success.
|
||||
*
|
||||
* ON UNFIXED CODE, THIS TEST WILL FAIL:
|
||||
* The current handler does not use transactions or clean up junction rows.
|
||||
* When pool.query receives the bare DELETE and junction rows exist, the mock
|
||||
* simulates the FK violation error that PostgreSQL would throw.
|
||||
* The handler catches the error and returns 500 instead of the expected 200.
|
||||
*
|
||||
* COUNTEREXAMPLE:
|
||||
* "DELETE FROM ivanti_todo_queue fails with FK violation when junction rows
|
||||
* reference completed items"
|
||||
*
|
||||
* **Validates: Requirements 1.1**
|
||||
*/
|
||||
|
||||
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: 42, username: 'testuser', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Programmable pg pool mock
|
||||
let queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
||||
|
||||
const mockClient = {
|
||||
query: jest.fn((text, params) => queryHandler(text, params)),
|
||||
release: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPool = {
|
||||
query: jest.fn((text, params) => queryHandler(text, params)),
|
||||
connect: jest.fn(() => Promise.resolve(mockClient)),
|
||||
};
|
||||
|
||||
jest.mock('../db', () => mockPool);
|
||||
|
||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
||||
function request(server, method, urlPath) {
|
||||
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 body;
|
||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
||||
resolve({ statusCode: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generators ---
|
||||
|
||||
/**
|
||||
* Generate a non-empty array of completed queue item IDs (simulating items
|
||||
* that belong to the authenticated user and have status = 'complete').
|
||||
*/
|
||||
const completedItemIdsArb = fc.array(
|
||||
fc.integer({ min: 1, max: 10000 }),
|
||||
{ minLength: 1, maxLength: 10 }
|
||||
).map(ids => [...new Set(ids)]); // ensure unique IDs
|
||||
|
||||
/**
|
||||
* Generate junction table links for a subset of completed items.
|
||||
* At least one item MUST have a junction link (this is the bug condition).
|
||||
*/
|
||||
const junctionLinksArb = (itemIds) => {
|
||||
// Generate at least 1 junction link, up to 3 per item
|
||||
return fc.array(
|
||||
fc.record({
|
||||
jira_ticket_id: fc.integer({ min: 1, max: 5000 }),
|
||||
queue_item_id: fc.constantFrom(...itemIds),
|
||||
}),
|
||||
{ minLength: 1, maxLength: Math.min(itemIds.length * 3, 15) }
|
||||
);
|
||||
};
|
||||
|
||||
// --- Test Suite ---
|
||||
|
||||
describe('Bug Condition Exploration: FK Violation on Clear Completed With Junction Table Links', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.query.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 1: Bug Condition - FK Violation on Clear Completed With Junction Table Links
|
||||
*
|
||||
* For any set of completed queue items where at least one has junction table links,
|
||||
* the FIXED handler should:
|
||||
* 1. Use a transaction (BEGIN/COMMIT)
|
||||
* 2. Delete junction table references first
|
||||
* 3. Delete the completed queue items
|
||||
* 4. Return 200 with { message, deleted: N }
|
||||
*
|
||||
* On UNFIXED code: The handler uses a bare pool.query DELETE which will receive
|
||||
* a FK violation error from our mock (simulating PostgreSQL behavior), causing
|
||||
* the handler to return 500. This test FAILS, confirming the bug exists.
|
||||
*
|
||||
* **Validates: Requirements 1.1**
|
||||
*/
|
||||
it('Property 1: completed items with junction links are deleted successfully (encodes expected fixed behavior)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
completedItemIdsArb,
|
||||
fc.context(),
|
||||
async (itemIds, ctx) => {
|
||||
// Generate junction links for these items
|
||||
const junctionLinks = itemIds.map(id => ({
|
||||
jira_ticket_id: id * 10,
|
||||
queue_item_id: id,
|
||||
}));
|
||||
|
||||
ctx.log(`Testing with ${itemIds.length} completed items, ${junctionLinks.length} junction links`);
|
||||
ctx.log(`Item IDs: ${JSON.stringify(itemIds)}`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.query.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Configure the query handler to simulate the bug condition:
|
||||
// - If the code uses pool.query with a bare DELETE on ivanti_todo_queue,
|
||||
// simulate the FK violation error (this is what the UNFIXED code does)
|
||||
// - If the code uses a transaction (BEGIN, SELECT, DELETE junction, DELETE queue, COMMIT),
|
||||
// simulate successful execution (this is what the FIXED code should do)
|
||||
const fkViolationError = new Error(
|
||||
'update or delete on table "ivanti_todo_queue" violates foreign key constraint ' +
|
||||
'"jira_ticket_queue_items_queue_item_id_fkey" on table "jira_ticket_queue_items"'
|
||||
);
|
||||
fkViolationError.code = '23503'; // PostgreSQL FK violation error code
|
||||
|
||||
// Handler for pool.query (unfixed code path)
|
||||
mockPool.query.mockImplementation((text, params) => {
|
||||
// The unfixed code issues a bare DELETE — simulate FK violation
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.reject(fkViolationError);
|
||||
}
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
// Handler for client.query (fixed code path — transaction-based)
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({
|
||||
rows: itemIds.map(id => ({ id })),
|
||||
rowCount: itemIds.length,
|
||||
});
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({
|
||||
rows: [],
|
||||
rowCount: junctionLinks.length,
|
||||
});
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({
|
||||
rows: [],
|
||||
rowCount: itemIds.length,
|
||||
});
|
||||
}
|
||||
if (text === 'COMMIT') {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text === 'ROLLBACK') {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
// Make the request
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
// Assert EXPECTED (fixed) behavior:
|
||||
// The handler should return 200 with the correct deleted count
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(itemIds.length);
|
||||
}
|
||||
),
|
||||
{ numRuns: 20, verbose: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// --- Preservation Property Tests ---
|
||||
|
||||
/**
|
||||
* Property 2: Preservation - Clear Completed Without Junction Table Links
|
||||
*
|
||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
||||
*
|
||||
* PRESERVATION GOAL:
|
||||
* Verify that the UNFIXED code already handles non-bug-condition cases correctly.
|
||||
* These tests establish a baseline that must be preserved after the fix is applied.
|
||||
*
|
||||
* NON-BUG-CONDITION CASES:
|
||||
* - Completed items WITHOUT junction table links → DELETE succeeds, returns count
|
||||
* - No completed items exist → returns { deleted: 0 }
|
||||
* - Pending/in-progress items → never touched by the DELETE
|
||||
*
|
||||
* The current handler uses:
|
||||
* pool.query("DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'", [req.user.id])
|
||||
* This works correctly when no FK violations occur (no junction table references).
|
||||
*
|
||||
* EXPECTED OUTCOME: All tests PASS on unfixed code.
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.2, 3.3**
|
||||
*/
|
||||
|
||||
describe('Preservation: Clear Completed Without Junction Table Links', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.query.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2a: Completed items without junction links are all deleted
|
||||
*
|
||||
* For any random count of completed items (1–20) belonging to the user,
|
||||
* when none have junction table links, the DELETE succeeds and returns
|
||||
* the correct count.
|
||||
*
|
||||
* The fixed code uses a transaction (client.query) so we mock client.query
|
||||
* to simulate successful execution without FK violations.
|
||||
*
|
||||
* **Validates: Requirements 3.1**
|
||||
*/
|
||||
it('Property 2a: completed items without junction links are deleted and correct count returned', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing with ${completedCount} completed items (no junction links)`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Generate item IDs
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
// Mock client.query for the transaction-based handler
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 }); // no junction links
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
// Assert preservation behavior
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(completedCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2b: When no completed items exist, returns deleted: 0
|
||||
*
|
||||
* When the user has no completed items, the SELECT returns empty rows
|
||||
* and the endpoint returns { message: 'Completed items cleared.', deleted: 0 }.
|
||||
*
|
||||
* **Validates: Requirements 3.2**
|
||||
*/
|
||||
it('Property 2b: no completed items returns deleted: 0', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.constant(null),
|
||||
fc.context(),
|
||||
async (_unused, ctx) => {
|
||||
ctx.log('Testing with 0 completed items');
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Mock client.query: SELECT returns empty rows (no completed items)
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 5, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2c: Pending/in-progress items are never touched
|
||||
*
|
||||
* The SELECT query only fetches items with status = 'complete' for the user.
|
||||
* We verify the query text and parameters to ensure non-complete items
|
||||
* are never affected.
|
||||
*
|
||||
* **Validates: Requirements 3.3**
|
||||
*/
|
||||
it('Property 2c: DELETE only targets complete status for the authenticated user', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 0, max: 15 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing query targeting with ${completedCount} completed items`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Track the queries issued via client
|
||||
const queriesIssued = [];
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
queriesIssued.push({ text, params });
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify the SELECT query targets only complete status for user 42
|
||||
const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('complete'));
|
||||
expect(selectQueries.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
for (const q of selectQueries) {
|
||||
expect(q.text).toMatch(/status\s*=\s*'\s*complete\s*'/i);
|
||||
expect(q.text).toMatch(/user_id\s*=/i);
|
||||
expect(q.params).toContain(42);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 20, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2d: Other users' items remain untouched
|
||||
*
|
||||
* The SELECT query is parameterized with req.user.id (mocked as 42).
|
||||
* For any random count of completed items, the query only affects user 42's items.
|
||||
* We verify the user_id parameter is always the authenticated user's ID.
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.3**
|
||||
*/
|
||||
it('Property 2d: DELETE is scoped to the authenticated user only', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing user isolation with ${completedCount} completed items`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
const queriesIssued = [];
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
queriesIssued.push({ text, params });
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.deleted).toBe(completedCount);
|
||||
|
||||
// Verify the SELECT query is scoped to user 42
|
||||
const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('user_id'));
|
||||
for (const q of selectQueries) {
|
||||
expect(q.params).toContain(42); // req.user.id from mock
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 20, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2e: Response shape is always { message: string, deleted: number }
|
||||
*
|
||||
* For any random count of completed items (including 0), the response
|
||||
* always has the correct shape with message as a string and deleted as a number.
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.2**
|
||||
*/
|
||||
it('Property 2e: response shape is { message: string, deleted: number }', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 0, max: 50 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing response shape with deleted count: ${completedCount}`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(typeof res.body.message).toBe('string');
|
||||
expect(typeof res.body.deleted).toBe('number');
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(completedCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 25, verbose: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
371
backend/__tests__/ivanti-queue-clear-completed-fix.test.js
Normal file
371
backend/__tests__/ivanti-queue-clear-completed-fix.test.js
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Unit tests for DELETE /api/ivanti/todo-queue/completed transaction logic
|
||||
*
|
||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.2, 3.1, 3.2
|
||||
*
|
||||
* Tests verify:
|
||||
* - Correct query sequence within a transaction (BEGIN → SELECT → DELETE junction → DELETE queue → COMMIT)
|
||||
* - ROLLBACK is called when any query in the transaction fails
|
||||
* - Client is always released in the finally block (even on error)
|
||||
* - Empty completed set triggers early COMMIT and returns { deleted: 0 }
|
||||
* - Response shape is { message: 'Completed items cleared.', deleted: N }
|
||||
*/
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, _res, next) => {
|
||||
req.user = { id: 7, username: 'testuser', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
const mockClient = {
|
||||
query: jest.fn(),
|
||||
release: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPool = {
|
||||
query: jest.fn(),
|
||||
connect: jest.fn(() => Promise.resolve(mockClient)),
|
||||
};
|
||||
|
||||
jest.mock('../db', () => mockPool);
|
||||
|
||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
||||
function request(server, method, urlPath) {
|
||||
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 body;
|
||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
||||
resolve({ statusCode: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Test Suite ---
|
||||
|
||||
describe('DELETE /api/ivanti/todo-queue/completed — Transaction Logic', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 1: Correct query sequence
|
||||
* Validates: Requirements 2.1, 2.2
|
||||
*
|
||||
* Verifies the handler issues queries in the correct order:
|
||||
* BEGIN → SELECT IDs → DELETE junction → DELETE queue → COMMIT
|
||||
*/
|
||||
it('executes queries in correct transaction order: BEGIN → SELECT → DELETE junction → DELETE queue → COMMIT', async () => {
|
||||
const completedIds = [10, 20, 30];
|
||||
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: completedIds.map(id => ({ id })), rowCount: 3 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify query sequence
|
||||
const calls = mockClient.query.mock.calls;
|
||||
expect(calls.length).toBe(5);
|
||||
|
||||
// BEGIN
|
||||
expect(calls[0][0]).toBe('BEGIN');
|
||||
|
||||
// SELECT completed IDs
|
||||
expect(calls[1][0]).toContain('SELECT');
|
||||
expect(calls[1][0]).toContain('ivanti_todo_queue');
|
||||
expect(calls[1][0]).toContain('complete');
|
||||
expect(calls[1][1]).toEqual([7]); // user id
|
||||
|
||||
// DELETE junction table references
|
||||
expect(calls[2][0]).toContain('DELETE FROM jira_ticket_queue_items');
|
||||
expect(calls[2][0]).toContain('ANY');
|
||||
expect(calls[2][1]).toEqual([completedIds]);
|
||||
|
||||
// DELETE queue items
|
||||
expect(calls[3][0]).toContain('DELETE FROM ivanti_todo_queue');
|
||||
expect(calls[3][0]).toContain('ANY');
|
||||
expect(calls[3][1]).toEqual([completedIds]);
|
||||
|
||||
// COMMIT
|
||||
expect(calls[4][0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 2: ROLLBACK on error
|
||||
* Validates: Requirements 2.1
|
||||
*
|
||||
* Verifies ROLLBACK is called when any query in the transaction fails.
|
||||
*/
|
||||
describe('ROLLBACK on error', () => {
|
||||
it('calls ROLLBACK when SELECT query fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockRejectedValueOnce(new Error('SELECT failed')); // SELECT throws
|
||||
|
||||
// After the catch, ROLLBACK is called
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls ROLLBACK when DELETE junction query fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 }) // SELECT
|
||||
.mockRejectedValueOnce(new Error('DELETE junction failed')); // DELETE junction throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls ROLLBACK when DELETE queue query fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 5 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
||||
.mockRejectedValueOnce(new Error('DELETE queue failed')); // DELETE queue throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls ROLLBACK when COMMIT fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
||||
.mockRejectedValueOnce(new Error('COMMIT failed')); // COMMIT throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 3: Client always released
|
||||
* Validates: Requirements 2.1
|
||||
*
|
||||
* Verifies client.release() is always called in the finally block,
|
||||
* even when an error occurs.
|
||||
*/
|
||||
describe('client always released', () => {
|
||||
it('releases client after successful transaction', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases client after failed transaction (error in SELECT)', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockRejectedValueOnce(new Error('DB error')); // SELECT throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases client after failed transaction (error in DELETE)', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 }) // SELECT
|
||||
.mockRejectedValueOnce(new Error('FK violation')); // DELETE junction throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases client when empty completed set triggers early return', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT (empty)
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 4: Empty completed set
|
||||
* Validates: Requirements 3.1, 3.2
|
||||
*
|
||||
* When SELECT returns no completed items, the handler should:
|
||||
* - Issue COMMIT (not ROLLBACK)
|
||||
* - Return { message: 'Completed items cleared.', deleted: 0 }
|
||||
* - NOT issue any DELETE queries
|
||||
*/
|
||||
it('empty completed set triggers early COMMIT and returns { deleted: 0 }', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT (empty)
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ message: 'Completed items cleared.', deleted: 0 });
|
||||
|
||||
// Verify query sequence: BEGIN → SELECT → COMMIT (no DELETEs)
|
||||
const calls = mockClient.query.mock.calls;
|
||||
expect(calls.length).toBe(3);
|
||||
expect(calls[0][0]).toBe('BEGIN');
|
||||
expect(calls[1][0]).toContain('SELECT');
|
||||
expect(calls[2][0]).toBe('COMMIT');
|
||||
|
||||
// No DELETE queries issued
|
||||
const deleteQueries = calls.filter(c => c[0].includes('DELETE'));
|
||||
expect(deleteQueries.length).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 5: Response shape preserved
|
||||
* Validates: Requirements 2.2, 3.1
|
||||
*
|
||||
* Verifies the response is always { message: 'Completed items cleared.', deleted: N }
|
||||
*/
|
||||
describe('response shape preserved', () => {
|
||||
it('returns correct shape with deleted count matching rowCount', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }], rowCount: 5 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 5 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
message: 'Completed items cleared.',
|
||||
deleted: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns correct shape with single deleted item', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 99 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
message: 'Completed items cleared.',
|
||||
deleted: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error shape on failure', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockRejectedValueOnce(new Error('Something broke')); // SELECT throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -441,15 +441,43 @@ function createIvantiTodoQueueRouter() {
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const result = await pool.query(
|
||||
"DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Select completed item IDs for this user
|
||||
const { rows: completedRows } = await client.query(
|
||||
"SELECT id FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ message: 'Completed items cleared.', deleted: result.rowCount });
|
||||
|
||||
if (completedRows.length === 0) {
|
||||
await client.query('COMMIT');
|
||||
return res.json({ message: 'Completed items cleared.', deleted: 0 });
|
||||
}
|
||||
|
||||
const ids = completedRows.map(r => r.id);
|
||||
|
||||
// Delete junction table references first
|
||||
await client.query(
|
||||
'DELETE FROM jira_ticket_queue_items WHERE queue_item_id = ANY($1::int[])',
|
||||
[ids]
|
||||
);
|
||||
|
||||
// Delete the completed queue items
|
||||
const deleteResult = await client.query(
|
||||
'DELETE FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
|
||||
[ids]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ message: 'Completed items cleared.', deleted: deleteResult.rowCount });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error clearing completed queue items:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user