Select multiple queue items and create a single consolidated Jira ticket with aggregated summary and description. Adds multi-select mode with checkboxes, floating action bar, consolidation modal, and junction table to track which queue items contributed to each ticket. - Migration: jira_ticket_queue_items junction table - POST /api/jira-tickets/:id/queue-items endpoint - GET /api/ivanti/todo-queue/ticket-links endpoint - ConsolidationModal component with aggregation logic - IvantiTodoQueuePage with selection mode and ticket link badges - Pure utility functions for summary/description generation - 34 tests passing (backend + frontend)
215 lines
7.2 KiB
JavaScript
215 lines
7.2 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|