372 lines
15 KiB
JavaScript
372 lines
15 KiB
JavaScript
|
|
/**
|
||
|
|
* 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.' });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|