Dealer_Onboarding_Backend/src/__tests__/workflow-service.test.ts

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);
});
});