/** * Unit Tests: POST /api/jira-tickets/:id/queue-items * * Feature: multi-item-jira-ticket * * Tests the junction endpoint that links queue items to a Jira ticket. * Validates: Requirements 5.3, 6.1, 6.2 */ const http = require('http'); const express = require('express'); // Mock the auth middleware so routes don't require real sessions/cookies. jest.mock('../middleware/auth', () => ({ requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'test', group: 'Admin' }; next(); }, requireGroup: (...groups) => (req, res, next) => next(), })); // Mock the audit log helper to be a no-op. jest.mock('../helpers/auditLog', () => jest.fn()); // Mock the jiraApi helper jest.mock('../helpers/jiraApi', () => ({ isConfigured: false, getRateLimitStatus: jest.fn(() => ({ burst: { remaining: 60, limit: 60 }, daily: { remaining: 1440, limit: 1440 }, })), })); const pool = require('../db'); jest.mock('../db', () => ({ query: jest.fn(), })); const createJiraTicketsRouter = require('../routes/jiraTickets'); /** * Helper: send an HTTP request to the test server and return { statusCode, body }. */ function request(server, method, path, body) { return new Promise((resolve, reject) => { const addr = server.address(); const options = { hostname: '127.0.0.1', port: addr.port, path, 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 parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; } resolve({ statusCode: res.statusCode, body: parsed }); }); }); req.on('error', reject); if (body) { req.write(JSON.stringify(body)); } req.end(); }); } describe('POST /api/jira-tickets/:id/queue-items', () => { let app; let server; beforeAll((done) => { app = express(); app.use(express.json()); app.use('/api/jira-tickets', createJiraTicketsRouter()); server = app.listen(0, '127.0.0.1', done); }); afterAll((done) => { server.close(done); }); beforeEach(() => { pool.query.mockReset(); }); // ------------------------------------------------------------------------- // Validation tests // ------------------------------------------------------------------------- it('returns 400 when queue_item_ids is missing', async () => { const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {}); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); }); it('returns 400 when queue_item_ids is an empty array', async () => { const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { queue_item_ids: [], }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); }); it('returns 400 when queue_item_ids is not an array', async () => { const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { queue_item_ids: 'not-an-array', }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); }); it('returns 400 when queue_item_ids contains non-integers', async () => { const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { queue_item_ids: [1, 2.5, 3], }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); }); it('returns 400 when queue_item_ids contains strings', async () => { const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { queue_item_ids: [1, 'abc', 3], }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); }); // ------------------------------------------------------------------------- // Ticket existence check // ------------------------------------------------------------------------- it('returns 404 when jira ticket does not exist', async () => { pool.query.mockResolvedValueOnce({ rows: [] }); // ticket lookup const res = await request(server, 'POST', '/api/jira-tickets/999/queue-items', { queue_item_ids: [1, 2, 3], }); expect(res.statusCode).toBe(404); expect(res.body.error).toBe('Jira ticket not found'); }); // ------------------------------------------------------------------------- // Queue item existence check // ------------------------------------------------------------------------- it('returns 400 when some queue items do not exist', async () => { pool.query .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists .mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); // only 2 of 3 exist const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { queue_item_ids: [1, 2, 3], }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('One or more queue items not found'); }); // ------------------------------------------------------------------------- // Successful linking // ------------------------------------------------------------------------- it('returns 201 with linked_count on success', async () => { pool.query .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists .mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }, { id: 18 }] }) // all queue items exist .mockResolvedValueOnce({ rowCount: 3 }); // insert result const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { queue_item_ids: [12, 15, 18], }); expect(res.statusCode).toBe(201); expect(res.body.message).toBe('Queue items linked to ticket'); expect(res.body.ticket_id).toBe(42); expect(res.body.linked_count).toBe(3); }); it('returns linked_count reflecting ON CONFLICT DO NOTHING (duplicates ignored)', async () => { pool.query .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists .mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }] }) // all queue items exist .mockResolvedValueOnce({ rowCount: 1 }); // only 1 new row (1 was duplicate) const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { queue_item_ids: [12, 15], }); expect(res.statusCode).toBe(201); expect(res.body.linked_count).toBe(1); }); // ------------------------------------------------------------------------- // Error handling // ------------------------------------------------------------------------- it('returns 500 on database error', async () => { pool.query .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists .mockResolvedValueOnce({ rows: [{ id: 12 }] }) // queue items exist .mockRejectedValueOnce(new Error('Connection lost')); // insert fails const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { queue_item_ids: [12], }); expect(res.statusCode).toBe(500); expect(res.body.error).toContain('Connection lost'); }); });