/** * 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.' }); }); }); });