/** * Unit and Integration Tests: FP Submissions Cleanup * * Feature: fp-submissions-cleanup * * Tests cover: * - Dismiss endpoint (happy path, wrong status, ownership check, not found) * - Filter edge cases (all approved, all dismissed, mixed, empty array) * - Integration: dismissed submissions remain in DB but are excluded from filtered list */ const http = require('http'); const express = require('express'); // Mock auth middleware to bypass real session checks jest.mock('../middleware/auth', () => ({ requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); }, requireGroup: () => (req, res, next) => next(), })); // Mock audit log as a no-op jest.mock('../helpers/auditLog', () => jest.fn()); // Mock ivantiApi to avoid real network calls jest.mock('../helpers/ivantiApi', () => ({ ivantiFormPost: jest.fn(), ivantiPost: jest.fn(), })); // Mock the db pool const mockPool = { query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), }; jest.mock('../db', () => mockPool); const createIvantiFpWorkflowRouter = require('../routes/ivantiFpWorkflow'); const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow'); // --- HTTP helper --- 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 body = Buffer.concat(chunks).toString(); let json; try { json = JSON.parse(body); } catch (e) { json = null; } resolve({ statusCode: res.statusCode, body: json }); }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } // --- Dismiss Endpoint Tests (Task 8.1) --- describe('PATCH /submissions/:id/dismiss', () => { let app, server; beforeAll((done) => { app = express(); app.use(express.json()); app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter()); server = app.listen(0, '127.0.0.1', done); }); afterAll((done) => { server.close(done); }); beforeEach(() => { mockPool.query.mockReset(); }); it('happy path — dismisses a rejected submission owned by the user', async () => { // First query: SELECT submission mockPool.query.mockResolvedValueOnce({ rows: [{ id: 42, user_id: 1, lifecycle_status: 'rejected', dismissed_at: null, ivanti_workflow_batch_id: 'WF-100' }], }); // Second query: UPDATE dismissed_at mockPool.query.mockResolvedValueOnce({ rowCount: 1 }); const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss'); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ success: true }); // Verify the UPDATE was called with the correct SQL pattern expect(mockPool.query).toHaveBeenCalledTimes(2); const updateCall = mockPool.query.mock.calls[1]; expect(updateCall[0]).toContain('dismissed_at'); expect(updateCall[1]).toContain('42'); }); it('returns 404 when submission does not exist', async () => { mockPool.query.mockResolvedValueOnce({ rows: [] }); const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/999/dismiss'); expect(res.statusCode).toBe(404); expect(res.body.error).toBe('Submission not found.'); }); it('returns 403 when user does not own the submission', async () => { mockPool.query.mockResolvedValueOnce({ rows: [{ id: 42, user_id: 99, // different user lifecycle_status: 'rejected', dismissed_at: null, ivanti_workflow_batch_id: 'WF-100' }], }); const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss'); expect(res.statusCode).toBe(403); expect(res.body.error).toBe('You can only dismiss your own submissions.'); }); it('returns 400 when submission is not in rejected status', async () => { mockPool.query.mockResolvedValueOnce({ rows: [{ id: 42, user_id: 1, lifecycle_status: 'submitted', dismissed_at: null, ivanti_workflow_batch_id: 'WF-100' }], }); const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss'); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('Only rejected submissions can be dismissed.'); }); }); // --- Filter Edge Cases (Task 8.2) --- describe('filterVisibleSubmissions — edge cases', () => { it('returns empty array when all submissions are approved', () => { const submissions = [ { id: 1, lifecycle_status: 'approved', dismissed_at: null }, { id: 2, lifecycle_status: 'approved', dismissed_at: null }, { id: 3, lifecycle_status: 'approved', dismissed_at: null }, ]; expect(filterVisibleSubmissions(submissions)).toEqual([]); }); it('returns empty array when all submissions are dismissed', () => { const submissions = [ { id: 1, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z' }, { id: 2, lifecycle_status: 'submitted', dismissed_at: '2026-04-15T08:00:00Z' }, { id: 3, lifecycle_status: 'rework', dismissed_at: '2026-03-20T10:00:00Z' }, ]; expect(filterVisibleSubmissions(submissions)).toEqual([]); }); it('returns correct subset for mixed statuses', () => { const submissions = [ { id: 1, lifecycle_status: 'approved', dismissed_at: null }, { id: 2, lifecycle_status: 'rejected', dismissed_at: null }, { id: 3, lifecycle_status: 'submitted', dismissed_at: '2026-05-01T12:00:00Z' }, { id: 4, lifecycle_status: 'rework', dismissed_at: null }, { id: 5, lifecycle_status: 'resubmitted', dismissed_at: null }, ]; const result = filterVisibleSubmissions(submissions); expect(result).toEqual([ { id: 2, lifecycle_status: 'rejected', dismissed_at: null }, { id: 4, lifecycle_status: 'rework', dismissed_at: null }, { id: 5, lifecycle_status: 'resubmitted', dismissed_at: null }, ]); }); it('returns empty array for empty input', () => { expect(filterVisibleSubmissions([])).toEqual([]); }); }); // --- Integration Test (Task 8.3) --- describe('Integration: dismissed submissions remain in DB but are excluded from filtered list', () => { it('dismissed submission is still in the database but excluded by filterVisibleSubmissions', async () => { // Simulate the full database state after a dismiss operation: // The submission record still exists with dismissed_at set const allSubmissionsInDb = [ { id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 }, { id: 2, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z', user_id: 1 }, { id: 3, lifecycle_status: 'approved', dismissed_at: null, user_id: 1 }, { id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 }, ]; // The dismissed submission (id: 2) is still in the database const dismissedSubmission = allSubmissionsInDb.find(s => s.id === 2); expect(dismissedSubmission).toBeDefined(); expect(dismissedSubmission.dismissed_at).not.toBeNull(); // But when we filter for visible submissions, it's excluded const visibleSubmissions = filterVisibleSubmissions(allSubmissionsInDb); // Dismissed submission (id: 2) is NOT in the visible list expect(visibleSubmissions.find(s => s.id === 2)).toBeUndefined(); // Approved submission (id: 3) is also NOT in the visible list expect(visibleSubmissions.find(s => s.id === 3)).toBeUndefined(); // Non-dismissed, non-approved submissions ARE in the visible list expect(visibleSubmissions).toEqual([ { id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 }, { id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 }, ]); // Verify the original array is unchanged (submissions remain in DB) expect(allSubmissionsInDb.length).toBe(4); expect(allSubmissionsInDb.find(s => s.id === 2)).toBeDefined(); }); });