/** * @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 = {}) => { 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); }); });