Add FP submissions cleanup: auto-clear approved, dismiss rejected, collapsible section
This commit is contained in:
93
backend/__tests__/fp-submissions-cleanup.property.test.js
Normal file
93
backend/__tests__/fp-submissions-cleanup.property.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Property-Based Tests: FP Submissions Cleanup
|
||||
*
|
||||
* Feature: fp-submissions-cleanup
|
||||
*
|
||||
* Tests the pure filtering functions used to determine which FP submissions
|
||||
* are visible in the Queue Panel and which show the dismiss button.
|
||||
*
|
||||
* Validates: Requirements 1.1, 2.1, 2.2, 2.3
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow');
|
||||
|
||||
// --- Generators ---
|
||||
|
||||
const lifecycleStatusArb = fc.constantFrom('submitted', 'approved', 'rejected', 'rework', 'resubmitted');
|
||||
|
||||
const dismissedAtArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }).map(d => d.toISOString())
|
||||
);
|
||||
|
||||
const submissionArb = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
lifecycle_status: lifecycleStatusArb,
|
||||
dismissed_at: dismissedAtArb,
|
||||
user_id: fc.integer({ min: 1, max: 1000 }),
|
||||
ivanti_workflow_batch_id: fc.string({ minLength: 1, maxLength: 20 })
|
||||
});
|
||||
|
||||
const submissionsArrayArb = fc.array(submissionArb, { minLength: 0, maxLength: 50 });
|
||||
|
||||
// --- Property 1: Submission Visibility Filter ---
|
||||
|
||||
describe('Feature: fp-submissions-cleanup, Property 1: Submission Visibility Filter', () => {
|
||||
/**
|
||||
* For any array of FP submission objects with arbitrary lifecycle_status values
|
||||
* and arbitrary dismissed_at values, filterVisibleSubmissions(submissions) should
|
||||
* return only submissions where lifecycle_status is NOT "approved" AND dismissed_at
|
||||
* is null. Additionally, every submission in the input that satisfies both conditions
|
||||
* must appear in the output, and the output length must be <= input length.
|
||||
*
|
||||
* Validates: Requirements 1.1, 2.2, 2.3
|
||||
*/
|
||||
it('returns only non-approved and non-dismissed submissions', () => {
|
||||
fc.assert(
|
||||
fc.property(submissionsArrayArb, (submissions) => {
|
||||
const result = filterVisibleSubmissions(submissions);
|
||||
|
||||
// Output length must be <= input length
|
||||
expect(result.length).toBeLessThanOrEqual(submissions.length);
|
||||
|
||||
// Every item in the result must be non-approved and non-dismissed
|
||||
for (const s of result) {
|
||||
expect(s.lifecycle_status).not.toBe('approved');
|
||||
expect(s.dismissed_at).toBeNull();
|
||||
}
|
||||
|
||||
// Every input item that satisfies both conditions must appear in the output
|
||||
const expected = submissions.filter(
|
||||
s => s.lifecycle_status !== 'approved' && s.dismissed_at == null
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 2: Dismiss Button Visibility Predicate ---
|
||||
|
||||
describe('Feature: fp-submissions-cleanup, Property 2: Dismiss Button Visibility Predicate', () => {
|
||||
/**
|
||||
* For any FP submission object with a lifecycle_status value drawn from
|
||||
* {submitted, approved, rejected, rework, resubmitted} and a dismissed_at value
|
||||
* (null or timestamp), the dismiss button should be rendered if and only if
|
||||
* lifecycle_status === 'rejected' AND dismissed_at is null.
|
||||
*
|
||||
* Validates: Requirements 2.1
|
||||
*/
|
||||
it('returns true iff status is rejected and dismissed_at is null', () => {
|
||||
fc.assert(
|
||||
fc.property(submissionArb, (submission) => {
|
||||
const result = shouldShowDismissButton(submission);
|
||||
const expected = submission.lifecycle_status === 'rejected' && submission.dismissed_at == null;
|
||||
|
||||
expect(result).toBe(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
240
backend/__tests__/fp-submissions-cleanup.test.js
Normal file
240
backend/__tests__/fp-submissions-cleanup.test.js
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user