299 lines
13 KiB
TypeScript
299 lines
13 KiB
TypeScript
/**
|
|
* @file workflow-service.test.ts
|
|
* @description Tests for WorkflowService.transitionApplication — verifies that
|
|
* each status transition triggers the correct notifications, audit log entries,
|
|
* and SLA tracking calls.
|
|
*
|
|
* SRS Coverage:
|
|
* §6.22 — Every workflow event is auto-logged in Audit Trail
|
|
* §6.14.3 — Status transitions trigger in-system + email + WhatsApp for applicant
|
|
* §6.16 — LOI Issued triggers LOI_ISSUED email template (not WhatsApp per SRS §1.1.2)
|
|
* §9.4 — SLA tracking starts/stops on each stage transition
|
|
*/
|
|
|
|
import { WorkflowService } from '../services/WorkflowService.js';
|
|
import { NotificationService } from '../services/NotificationService.js';
|
|
import { SLAService } from '../services/SLAService.js';
|
|
|
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
|
|
|
jest.mock('../database/models/index.js', () => {
|
|
const mockUpdate = jest.fn().mockResolvedValue(true);
|
|
const mockCreate = jest.fn().mockResolvedValue({ id: 'hist-1' });
|
|
const mockFindOne = jest.fn().mockResolvedValue(null);
|
|
const mockFindByPk = jest.fn().mockResolvedValue({ fullName: 'Test Actor' });
|
|
|
|
return {
|
|
default: {
|
|
Application: {},
|
|
ApplicationStatusHistory: { create: mockCreate },
|
|
User: { findOne: mockFindOne, findByPk: mockFindByPk },
|
|
Dealer: {},
|
|
StageApprovalPolicy: { findOne: jest.fn().mockResolvedValue(null) },
|
|
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
|
},
|
|
__mocks__: { mockUpdate, mockCreate, mockFindOne },
|
|
};
|
|
});
|
|
|
|
jest.mock('../common/utils/progress.js', () => ({
|
|
syncApplicationProgress: jest.fn().mockResolvedValue(undefined),
|
|
PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: {},
|
|
}));
|
|
|
|
jest.mock('../common/config/constants.js', () => ({
|
|
AUDIT_ACTIONS: { UPDATED: 'UPDATED' },
|
|
APPLICATION_STAGES: { SHORTLISTED: 'Shortlisted', LOI_ISSUED: 'LOI Issued' },
|
|
OVERALL_STATUS_TO_DB_CURRENT_STAGE: {},
|
|
}));
|
|
|
|
jest.mock('../common/utils/workflow-email-notifications.js', () => ({
|
|
notifyStakeholdersOnTransition: jest.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
jest.mock('../services/applicationAuditLog.service.js', () => ({
|
|
pickApplicationAuditContext: jest.fn().mockReturnValue({}),
|
|
safeAuditLogCreate: jest.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const mockSLAStop = jest.spyOn(SLAService, 'stopTrack').mockResolvedValue(undefined as any);
|
|
const mockSLAStart = jest.spyOn(SLAService, 'startTrack').mockResolvedValue(undefined as any);
|
|
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
|
|
|
process.env.FRONTEND_URL = 'http://localhost:5173';
|
|
|
|
// ─── Application fixture ─────────────────────────────────────────────────────
|
|
|
|
const makeApp = (overrides: Record<string, any> = {}) => {
|
|
const app: any = {
|
|
id: 'app-uuid-001',
|
|
applicationId: 'APP-2026-001',
|
|
overallStatus: 'Shortlisted',
|
|
currentStage: 'Shortlisted',
|
|
progressPercentage: 20,
|
|
email: 'applicant@gmail.com',
|
|
applicantName: 'Rahul Verma',
|
|
mobileNumber: '+919876543210',
|
|
update: jest.fn().mockResolvedValue(true),
|
|
...overrides,
|
|
};
|
|
return app;
|
|
};
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('WorkflowService.transitionApplication — status transitions', () => {
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
it('TC-WS-01: updates application overallStatus to the target status', async () => {
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
|
expect(app.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ overallStatus: 'Level 1 Interview Pending' })
|
|
);
|
|
});
|
|
|
|
it('TC-WS-02: creates an ApplicationStatusHistory record on each transition', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'Level 2 Interview Pending', 'user-2');
|
|
expect(db.ApplicationStatusHistory.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
applicationId: 'app-uuid-001',
|
|
previousStatus: 'Shortlisted',
|
|
newStatus: 'Level 2 Interview Pending',
|
|
changedBy: 'user-2',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('TC-WS-03: skips redundant status history when status is already at target', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
const app = makeApp({ overallStatus: 'Level 1 Interview Pending' });
|
|
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
|
expect(db.ApplicationStatusHistory.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TC-WS-04: calls safeAuditLogCreate with oldData and newData on each transition', async () => {
|
|
const { safeAuditLogCreate } = await import('../services/applicationAuditLog.service.js');
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-fdd');
|
|
expect(safeAuditLogCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'UPDATED',
|
|
entityType: 'application',
|
|
entityId: 'app-uuid-001',
|
|
oldData: expect.objectContaining({ status: 'Shortlisted' }),
|
|
newData: expect.objectContaining({ status: 'FDD Verification' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('TC-WS-05: stops SLA tracking for the previous stage and starts for the new stage', async () => {
|
|
const app = makeApp({ currentStage: 'Level 1 Interview Pending' });
|
|
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-1');
|
|
expect(mockSLAStop).toHaveBeenCalledWith('app-uuid-001', 'Level 1 Interview Pending');
|
|
});
|
|
|
|
it('TC-WS-06: triggers NotificationService.notify for the applicant on any status transition', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-admin');
|
|
expect(mockNotify).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
'applicant@gmail.com',
|
|
expect.objectContaining({
|
|
title: expect.stringContaining('Onboarding Update'),
|
|
channels: expect.arrayContaining(['email', 'system']),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('TC-WS-07: uses LOI_ISSUED template when target status is "LOI Issued"', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'LOI Issued', 'admin-1');
|
|
expect(mockNotify).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'applicant@gmail.com',
|
|
expect.objectContaining({ templateCode: 'LOI_ISSUED' })
|
|
);
|
|
});
|
|
|
|
it('TC-WS-08: uses LOA_ISSUED template when target status is "LOA Issued"', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'LOA Issued', 'admin-1');
|
|
expect(mockNotify).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'applicant@gmail.com',
|
|
expect.objectContaining({ templateCode: 'LOA_ISSUED' })
|
|
);
|
|
});
|
|
|
|
it('TC-WS-09: uses DEALER_CODE_READY template when target status is "Dealer Code Generated"', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: null });
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'Dealer Code Generated', 'admin-1');
|
|
expect(mockNotify).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'applicant@gmail.com',
|
|
expect.objectContaining({ templateCode: 'DEALER_CODE_READY' })
|
|
);
|
|
});
|
|
|
|
it('TC-WS-10: notifyStakeholdersOnTransition is called for every transition', async () => {
|
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'ZBH Review', 'user-zbh');
|
|
expect(notifyStakeholdersOnTransition).toHaveBeenCalledWith(
|
|
'app-uuid-001',
|
|
'application',
|
|
'ZBH Review',
|
|
expect.objectContaining({
|
|
code: 'APP-2026-001',
|
|
dealerName: 'Rahul Verma',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('TC-WS-11: skips applicant notification when skipNotification metadata is true', async () => {
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'Shortlisted', 'admin-1', {
|
|
skipNotification: true,
|
|
forceLog: true,
|
|
});
|
|
expect(mockNotify).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TC-WS-12: includes applicant mobileNumber in WhatsApp placeholders', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.User.findOne.mockResolvedValueOnce({
|
|
id: 'sys-user-1',
|
|
mobileNumber: '+919876543210',
|
|
});
|
|
const app = makeApp();
|
|
await WorkflowService.transitionApplication(app, 'FDD Verification', 'admin-1');
|
|
const notifyCall = mockNotify.mock.calls.find(
|
|
(c) => c[1] === 'applicant@gmail.com'
|
|
);
|
|
expect(notifyCall?.[2].placeholders?.phone).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── WorkflowService.evaluateStagePolicy ─────────────────────────────────────
|
|
|
|
describe('WorkflowService.evaluateStagePolicy — multi-role gate logic', () => {
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
it('TC-WESP-01: returns policyMet=true when no active policy exists for the stage', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce(null);
|
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'SOME_STAGE');
|
|
expect(result.policyMet).toBe(true);
|
|
});
|
|
|
|
it('TC-WESP-02: Super Admin bypass — always returns policyMet=true', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
|
requiredRoles: ['DD_ZM', 'RBM'],
|
|
approvalMode: 'ALL',
|
|
minApprovals: 2,
|
|
});
|
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
|
{ actorUserId: 'su-1', actorRole: 'Super Admin', decision: 'Approved' },
|
|
]);
|
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
|
expect(result.policyMet).toBe(true);
|
|
expect(result.overriddenBy).toBe('Super Admin');
|
|
});
|
|
|
|
it('TC-WESP-03: MIN_N mode — policyMet=true when minApprovals threshold is reached', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
|
requiredRoles: ['DD_ZM', 'RBM'],
|
|
approvalMode: 'MIN_N',
|
|
minApprovals: 1,
|
|
});
|
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
|
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
|
]);
|
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
|
expect(result.policyMet).toBe(true);
|
|
});
|
|
|
|
it('TC-WESP-04: ALL mode — policyMet=false when only one of two required roles responded', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
|
requiredRoles: ['DD_ZM', 'RBM'],
|
|
approvalMode: 'ALL',
|
|
minApprovals: 2,
|
|
});
|
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
|
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
|
// RBM has NOT responded yet
|
|
]);
|
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
|
expect(result.policyMet).toBe(false);
|
|
});
|
|
|
|
it('TC-WESP-05: ALL mode — policyMet=true when both RBM and DD_ZM have responded', async () => {
|
|
const db = (await import('../database/models/index.js')).default as any;
|
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
|
requiredRoles: ['DD_ZM', 'RBM'],
|
|
approvalMode: 'ALL',
|
|
minApprovals: 2,
|
|
});
|
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
|
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
|
{ actorUserId: 'rbm-1', actorRole: 'RBM', decision: 'Approved' },
|
|
]);
|
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
|
expect(result.policyMet).toBe(true);
|
|
});
|
|
});
|