diff --git a/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js b/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js new file mode 100644 index 0000000..208f4ed --- /dev/null +++ b/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js @@ -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 } + ); + }); +}); diff --git a/backend/__tests__/ivanti-queue-clear-completed-fix.test.js b/backend/__tests__/ivanti-queue-clear-completed-fix.test.js new file mode 100644 index 0000000..a022b95 --- /dev/null +++ b/backend/__tests__/ivanti-queue-clear-completed-fix.test.js @@ -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.' }); + }); + }); +}); diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 5318350..59e1eec 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -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(); } });