Add multi-item Jira ticket creation from Ivanti Queue
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)
This commit is contained in:
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Unit tests for GET /api/ivanti/todo-queue/ticket-links endpoint
|
||||
* Validates: Requirements 6.3, 6.4
|
||||
*/
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, _res, next) => {
|
||||
req.user = { id: 7, username: 'testuser' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock audit log
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock the db pool
|
||||
jest.mock('../db', () => ({
|
||||
query: jest.fn(() => Promise.resolve({ rows: [] })),
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require('../db');
|
||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||
|
||||
/**
|
||||
* Helper: send an HTTP request and return { statusCode, body }.
|
||||
*/
|
||||
function request(server, method, path) {
|
||||
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 body;
|
||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
||||
resolve({ statusCode: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/ivanti/todo-queue/ticket-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();
|
||||
});
|
||||
|
||||
it('returns an empty links object when no associations exist', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ links: {} });
|
||||
});
|
||||
|
||||
it('returns a map of queue_item_id to ticket info', async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ queue_item_id: 12, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
{ queue_item_id: 15, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
{ queue_item_id: 22, ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
links: {
|
||||
'12': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
'15': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
'22': { ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by the authenticated user ID', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
const [sql, params] = pool.query.mock.calls[0];
|
||||
expect(sql).toContain('q.user_id = $1');
|
||||
expect(params).toEqual([7]);
|
||||
});
|
||||
|
||||
it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
const [sql] = pool.query.mock.calls[0];
|
||||
expect(sql).toContain('jira_ticket_queue_items');
|
||||
expect(sql).toContain('JOIN jira_tickets');
|
||||
expect(sql).toContain('JOIN ivanti_todo_queue');
|
||||
});
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
pool.query.mockRejectedValueOnce(new Error('DB connection failed'));
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
});
|
||||
});
|
||||
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user