/** * Tests for src/workflows/wf.opportunity.ts * * Covers: * - Exported constants (OpportunityStatus, StatusIdToKey, OptimaType, WorkflowPermissions) * - Guard helpers (assertOptimaStage, assertNotTerminal, assertTransitionAllowed, assertNotePresent) * - Transition functions (transitionToNew, transitionToInternalReview, handleReviewDecision, * transitionToQuoteSent, transitionToConfirmedQuote, finalizeOpportunity, transitionToPending, * resurrectOpportunity, beginRevision, cancelOpportunity, reopenCancelledOpportunity, * triggerColdDetection) * - Master dispatcher (processOpportunityAction) */ import { describe, test, expect, mock, beforeEach } from "bun:test"; // --------------------------------------------------------------------------- // Mock dependencies before importing the workflow module // --------------------------------------------------------------------------- // Instead of mocking ActivityController directly (which contaminates the // global module registry and breaks ActivityController.test.ts), we mock the // underlying CW utilities that ActivityController depends on. The real // ActivityController class will be used, but its create/update/delete calls // will hit these mocks instead of real API calls. const mockCwActivityCreate = mock(() => Promise.resolve({ id: 9001, name: "Mock Activity", notes: null, type: { id: 3, name: "HistoricEntry" }, status: { id: 2, name: "Closed" }, company: null, contact: null, opportunity: { id: 1001, name: "Test Opp" }, assignTo: { id: 10, name: "Test User", identifier: "tuser" }, customFields: [], _info: {}, }), ); const mockCwActivityUpdate = mock((id: number, ops: any) => Promise.resolve({ id, name: "Mock Activity", notes: null, type: { id: 3, name: "HistoricEntry" }, status: { id: 2, name: "Closed" }, company: null, contact: null, opportunity: { id: 1001, name: "Test Opp" }, assignTo: { id: 10, name: "Test User", identifier: "tuser" }, customFields: [], _info: {}, }), ); const mockFetchByOpportunityDirect = mock(() => Promise.resolve([])); mock.module("../../src/modules/cw-utils/activities/activities", () => ({ activityCw: { create: mockCwActivityCreate, update: mockCwActivityUpdate, delete: mock(() => Promise.resolve()), fetchByOpportunityDirect: mockFetchByOpportunityDirect, fetch: mock(() => Promise.resolve({ id: 9001, name: "Mock", _info: {} })), fetchAll: mock(() => Promise.resolve(new Map())), fetchByCompany: mock(() => Promise.resolve(new Map())), fetchByOpportunity: mock(() => Promise.resolve(new Map())), fetchAllSummaries: mock(() => Promise.resolve(new Map())), countItems: mock(() => Promise.resolve(0)), replace: mock(() => Promise.resolve({ id: 9001 })), }, })); // Also mock fetchActivity used by ActivityController.refreshFromCW mock.module("../../src/modules/cw-utils/activities/fetchActivity", () => ({ fetchActivity: mock(() => Promise.resolve({ id: 9001, name: "Mock", _info: {} }), ), })); const mockSyncOpportunityStatus = mock(() => Promise.resolve()); const mockSubmitTimeEntry = mock(() => Promise.resolve()); mock.module("../../src/services/cw.opportunityService", () => ({ syncOpportunityStatus: mockSyncOpportunityStatus, submitTimeEntry: mockSubmitTimeEntry, })); const mockCheckColdStatus = mock(() => ({ cold: false, triggeredBy: null })); mock.module("../../src/modules/algorithms/algo.coldThreshold", () => ({ checkColdStatus: mockCheckColdStatus, // Include all named exports to avoid poisoning other test files that // import COLD_THRESHOLDS or types from this module. COLD_THRESHOLDS: { 43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 }, 57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 }, }, })); // --------------------------------------------------------------------------- // Import the module under test (after mocks are in place) // --------------------------------------------------------------------------- import { OpportunityStatus, StatusIdToKey, OptimaType, WorkflowPermissions, processOpportunityAction, transitionToNew, transitionToInternalReview, handleReviewDecision, transitionToQuoteSent, transitionToConfirmedQuote, finalizeOpportunity, transitionToPending, resurrectOpportunity, beginRevision, cancelOpportunity, reopenCancelledOpportunity, triggerColdDetection, type WorkflowUser, type WorkflowResult, } from "../../src/workflows/wf.opportunity"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeUser(overrides: Partial = {}): WorkflowUser { return { id: "user-1", cwMemberId: 10, permissions: ["*"], // all permissions by default ...overrides, }; } function makeOpportunity(overrides: Record = {}): any { return { cwOpportunityId: 1001, companyCwId: 123, name: "Test Opportunity", statusCwId: OpportunityStatus.PendingNew, stageName: "Optima", refreshFromCW: mock(() => Promise.resolve()), ...overrides, }; } beforeEach(() => { mockCwActivityCreate.mockClear(); mockCwActivityCreate.mockImplementation(() => Promise.resolve({ id: 9001, name: "Mock Activity", notes: null, type: { id: 3, name: "HistoricEntry" }, status: { id: 2, name: "Closed" }, company: null, contact: null, opportunity: { id: 1001, name: "Test Opp" }, assignTo: { id: 10, name: "Test User", identifier: "tuser" }, customFields: [], _info: {}, }), ); mockCwActivityUpdate.mockClear(); mockCwActivityUpdate.mockImplementation((id: number) => Promise.resolve({ id, name: "Mock Activity", notes: null, type: { id: 3, name: "HistoricEntry" }, status: { id: 2, name: "Closed" }, company: null, contact: null, opportunity: { id: 1001, name: "Test Opp" }, assignTo: { id: 10, name: "Test User", identifier: "tuser" }, customFields: [], _info: {}, }), ); mockFetchByOpportunityDirect.mockClear(); mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([])); mockSyncOpportunityStatus.mockClear(); mockSyncOpportunityStatus.mockImplementation(() => Promise.resolve()); mockSubmitTimeEntry.mockClear(); mockSubmitTimeEntry.mockImplementation(() => Promise.resolve()); mockCheckColdStatus.mockClear(); mockCheckColdStatus.mockImplementation(() => ({ cold: false, triggeredBy: null, })); }); // ═══════════════════════════════════════════════════════════════════════════ // CONSTANTS // ═══════════════════════════════════════════════════════════════════════════ describe("Exported constants", () => { test("OpportunityStatus has all expected keys", () => { expect(OpportunityStatus.PendingNew).toBe(37); expect(OpportunityStatus.New).toBe(24); expect(OpportunityStatus.InternalReview).toBe(56); expect(OpportunityStatus.QuoteSent).toBe(43); expect(OpportunityStatus.ConfirmedQuote).toBe(57); expect(OpportunityStatus.Active).toBe(58); expect(OpportunityStatus.PendingSent).toBe(60); expect(OpportunityStatus.PendingRevision).toBe(61); expect(OpportunityStatus.PendingWon).toBe(49); expect(OpportunityStatus.Won).toBe(29); expect(OpportunityStatus.PendingLost).toBe(50); expect(OpportunityStatus.Lost).toBe(53); expect(OpportunityStatus.Canceled).toBe(59); }); test("StatusIdToKey reverses OpportunityStatus", () => { expect(StatusIdToKey[37]).toBe("PendingNew"); expect(StatusIdToKey[24]).toBe("New"); expect(StatusIdToKey[29]).toBe("Won"); expect(StatusIdToKey[53]).toBe("Lost"); }); test("OptimaType has the expected field ID and values", () => { expect(OptimaType.FIELD_ID).toBe(45); expect(OptimaType.OpportunityCreated).toBe("Opportunity Created"); expect(OptimaType.QuoteSent).toBe("Quote Sent"); expect(OptimaType.Converted).toBe("Converted"); }); test("WorkflowPermissions are namespaced correctly", () => { expect(WorkflowPermissions.FINALIZE).toBe("sales.opportunity.finalize"); expect(WorkflowPermissions.CANCEL).toBe("sales.opportunity.cancel"); expect(WorkflowPermissions.REVIEW).toBe("sales.opportunity.review"); expect(WorkflowPermissions.SEND).toBe("sales.opportunity.send"); expect(WorkflowPermissions.REOPEN).toBe("sales.opportunity.reopen"); expect(WorkflowPermissions.WIN).toBe("sales.opportunity.win"); expect(WorkflowPermissions.LOSE).toBe("sales.opportunity.lose"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // TRANSITION: PendingNew → New // ═══════════════════════════════════════════════════════════════════════════ describe("transitionToNew", () => { test("succeeds when status is PendingNew", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew }); const user = makeUser(); const result = await transitionToNew(opp, user, {}); expect(result.success).toBe(true); expect(result.previousStatusId).toBe(OpportunityStatus.PendingNew); expect(result.newStatusId).toBe(OpportunityStatus.New); expect(result.activitiesCreated).toHaveLength(1); expect(mockSyncOpportunityStatus).toHaveBeenCalledTimes(1); }); test("fails when status is not PendingNew", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.Active }); const result = await transitionToNew(opp, makeUser(), {}); expect(result.success).toBe(false); expect(result.error).toBeTruthy(); }); test("defaults to PendingNew when statusCwId is null", async () => { const opp = makeOpportunity({ statusCwId: null }); const result = await transitionToNew(opp, makeUser(), {}); // transitionToNew defaults null → PendingNew, so transition succeeds expect(result.success).toBe(true); expect(result.previousStatusId).toBe(OpportunityStatus.PendingNew); expect(result.newStatusId).toBe(OpportunityStatus.New); }); test("submits time entry when timeStarted/timeEnded provided", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew }); await transitionToNew(opp, makeUser(), { timeStarted: "2026-03-01T09:00:00Z", timeEnded: "2026-03-01T10:00:00Z", }); expect(mockSubmitTimeEntry).toHaveBeenCalledTimes(1); }); }); // ═══════════════════════════════════════════════════════════════════════════ // TRANSITION: → InternalReview // ═══════════════════════════════════════════════════════════════════════════ describe("transitionToInternalReview", () => { test("succeeds from New with note", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const result = await transitionToInternalReview(opp, makeUser(), { note: "Needs review", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.InternalReview); }); test("requires REVIEW permission", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const user = makeUser({ permissions: [] }); const result = await transitionToInternalReview(opp, user, { note: "review", }); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.REVIEW); }); test("requires a non-empty note", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const result = await transitionToInternalReview(opp, makeUser(), { note: "", }); expect(result.success).toBe(false); expect(result.error).toBeTruthy(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // REVIEW DECISION // ═══════════════════════════════════════════════════════════════════════════ describe("handleReviewDecision", () => { test("approve → PendingSent", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.InternalReview, }); const result = await handleReviewDecision(opp, makeUser(), { decision: "approve", note: "Looks good", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.PendingSent); }); test("reject → PendingRevision", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.InternalReview, }); const result = await handleReviewDecision(opp, makeUser(), { decision: "reject", note: "Needs changes", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.PendingRevision); }); test("send → QuoteSent", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.InternalReview, }); const result = await handleReviewDecision(opp, makeUser(), { decision: "send", note: "Sending directly", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent); // "send" creates TWO activities (approved + sent) expect(result.activitiesCreated).toHaveLength(2); }); test("cancel → Canceled (requires CANCEL permission)", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.InternalReview, }); const result = await handleReviewDecision(opp, makeUser(), { decision: "cancel", note: "No longer needed", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Canceled); }); test("cancel fails without CANCEL permission", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.InternalReview, }); const user = makeUser({ permissions: [WorkflowPermissions.REVIEW], // no CANCEL }); const result = await handleReviewDecision(opp, user, { decision: "cancel", note: "No longer needed", }); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.CANCEL); }); test("fails when status is not InternalReview", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const result = await handleReviewDecision(opp, makeUser(), { decision: "approve", note: "ok", }); expect(result.success).toBe(false); expect(result.error).toContain("InternalReview"); }); test("requires note", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.InternalReview, }); const result = await handleReviewDecision(opp, makeUser(), { decision: "approve", note: "", }); expect(result.success).toBe(false); }); test("unknown decision returns failure", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.InternalReview, }); const result = await handleReviewDecision(opp, makeUser(), { decision: "unknown" as any, note: "Something", }); expect(result.success).toBe(false); expect(result.error).toContain("Unknown review decision"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // TRANSITION: → QuoteSent (with compound flags) // ═══════════════════════════════════════════════════════════════════════════ describe("transitionToQuoteSent", () => { test("plain send from PendingSent → QuoteSent", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const result = await transitionToQuoteSent(opp, makeUser(), {}); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent); }); test("requires SEND permission", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const user = makeUser({ permissions: [] }); const result = await transitionToQuoteSent(opp, user, {}); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.SEND); }); test("won flag → PendingWon (without finalize perm)", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const user = makeUser({ permissions: [WorkflowPermissions.SEND, WorkflowPermissions.WIN], }); const result = await transitionToQuoteSent(opp, user, { won: true }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.PendingWon); }); test("won + finalize → Won (with finalize perm)", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const user = makeUser(); // wildcard perms const result = await transitionToQuoteSent(opp, user, { won: true, finalize: true, }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Won); expect(result.activitiesCreated.length).toBeGreaterThanOrEqual(2); }); test("lost flag → PendingLost (without finalize perm)", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const user = makeUser({ permissions: [WorkflowPermissions.SEND, WorkflowPermissions.LOSE], }); const result = await transitionToQuoteSent(opp, user, { lost: true }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.PendingLost); }); test("lost + finalize → Lost (with finalize perm)", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const user = makeUser(); const result = await transitionToQuoteSent(opp, user, { lost: true, finalize: true, }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Lost); }); test("needsRevision → Active", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const result = await transitionToQuoteSent(opp, makeUser(), { needsRevision: true, }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Active); expect(result.activitiesCreated).toHaveLength(2); }); test("quoteConfirmed → ConfirmedQuote", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const result = await transitionToQuoteSent(opp, makeUser(), { quoteConfirmed: true, }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.ConfirmedQuote); }); test("won flag without WIN perm fails", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent }); const user = makeUser({ permissions: [WorkflowPermissions.SEND] }); const result = await transitionToQuoteSent(opp, user, { won: true }); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.WIN); }); }); // ═══════════════════════════════════════════════════════════════════════════ // TRANSITION: → ConfirmedQuote // ═══════════════════════════════════════════════════════════════════════════ describe("transitionToConfirmedQuote", () => { test("succeeds from QuoteSent", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent }); const result = await transitionToConfirmedQuote(opp, makeUser(), {}); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.ConfirmedQuote); }); test("fails from disallowed status", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const result = await transitionToConfirmedQuote(opp, makeUser(), {}); expect(result.success).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // FINALIZE // ═══════════════════════════════════════════════════════════════════════════ describe("finalizeOpportunity", () => { test("won from PendingWon → Won", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon }); const result = await finalizeOpportunity(opp, makeUser(), { outcome: "won", note: "Deal closed", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Won); }); test("lost from PendingLost → Lost", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost }); const result = await finalizeOpportunity(opp, makeUser(), { outcome: "lost", note: "Customer chose competitor", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Lost); }); test("requires FINALIZE permission", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon }); const user = makeUser({ permissions: [WorkflowPermissions.WIN] }); const result = await finalizeOpportunity(opp, user, { outcome: "won", note: "Close it", }); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.FINALIZE); }); test("requires a note", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon }); const result = await finalizeOpportunity(opp, makeUser(), { outcome: "won", note: "", }); expect(result.success).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // TRANSITION TO PENDING (Win/Lose without finalize perm) // ═══════════════════════════════════════════════════════════════════════════ describe("transitionToPending", () => { test("won from QuoteSent → PendingWon", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent }); const result = await transitionToPending(opp, makeUser(), { outcome: "won", note: "Customer accepted", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.PendingWon); }); test("lost from ConfirmedQuote → PendingLost", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.ConfirmedQuote, }); const result = await transitionToPending(opp, makeUser(), { outcome: "lost", note: "Declined", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.PendingLost); }); test("requires WIN permission for won outcome", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent }); const user = makeUser({ permissions: [] }); const result = await transitionToPending(opp, user, { outcome: "won", note: "Accepted", }); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.WIN); }); }); // ═══════════════════════════════════════════════════════════════════════════ // RESURRECT // ═══════════════════════════════════════════════════════════════════════════ describe("resurrectOpportunity", () => { test("PendingLost → Active", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost }); const result = await resurrectOpportunity(opp, makeUser(), { note: "Reconsider", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Active); }); test("PendingWon → Active requires FINALIZE perm", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon }); const user = makeUser({ permissions: [] }); const result = await resurrectOpportunity(opp, user, { note: "Revise", }); expect(result.success).toBe(false); expect(result.error).toContain("permission"); }); test("requires note", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost }); const result = await resurrectOpportunity(opp, makeUser(), { note: "" }); expect(result.success).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // BEGIN REVISION // ═══════════════════════════════════════════════════════════════════════════ describe("beginRevision", () => { test("PendingRevision → Active", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingRevision, }); const result = await beginRevision(opp, makeUser(), {}); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Active); }); test("fails from wrong status", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const result = await beginRevision(opp, makeUser(), {}); expect(result.success).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // CANCEL // ═══════════════════════════════════════════════════════════════════════════ describe("cancelOpportunity", () => { test("New → Canceled", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const result = await cancelOpportunity(opp, makeUser(), { note: "No longer needed", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Canceled); }); test("requires CANCEL permission", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const user = makeUser({ permissions: [] }); const result = await cancelOpportunity(opp, user, { note: "Cancel", }); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.CANCEL); }); test("requires note", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.New }); const result = await cancelOpportunity(opp, makeUser(), { note: "" }); expect(result.success).toBe(false); }); test("fails from terminal status", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.Won }); const result = await cancelOpportunity(opp, makeUser(), { note: "Can't cancel Won", }); expect(result.success).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // REOPEN // ═══════════════════════════════════════════════════════════════════════════ describe("reopenCancelledOpportunity", () => { test("Canceled → Active", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled }); const result = await reopenCancelledOpportunity(opp, makeUser(), { note: "Reopening for updated scope", }); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Active); }); test("requires REOPEN permission", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled }); const user = makeUser({ permissions: [] }); const result = await reopenCancelledOpportunity(opp, user, { note: "Reopen", }); expect(result.success).toBe(false); expect(result.error).toContain(WorkflowPermissions.REOPEN); }); test("requires note", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled }); const result = await reopenCancelledOpportunity(opp, makeUser(), { note: "", }); expect(result.success).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // COLD DETECTION // ═══════════════════════════════════════════════════════════════════════════ describe("triggerColdDetection", () => { test("returns success with no status change when not cold", async () => { mockCheckColdStatus.mockReturnValue({ cold: false, triggeredBy: null }); const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent }); const result = await triggerColdDetection(opp, new Date("2026-03-01")); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent); expect(result.activitiesCreated).toHaveLength(0); expect(mockSyncOpportunityStatus).not.toHaveBeenCalled(); }); test("transitions to InternalReview when cold", async () => { mockCheckColdStatus.mockReturnValue({ cold: true, triggeredBy: { statusCwId: 43, statusName: "QuoteSent", thresholdDays: 14, staleDays: 20, }, }); const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent }); const result = await triggerColdDetection(opp, new Date("2026-01-01")); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.InternalReview); expect(result.coldCheck?.cold).toBe(true); expect(result.activitiesCreated).toHaveLength(1); expect(mockSyncOpportunityStatus).toHaveBeenCalledTimes(1); }); test("fails when statusCwId is null", async () => { const opp = makeOpportunity({ statusCwId: null }); const result = await triggerColdDetection(opp, null); expect(result.success).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // MASTER DISPATCHER // ═══════════════════════════════════════════════════════════════════════════ describe("processOpportunityAction", () => { test("rejects non-Optima stage", async () => { const opp = makeOpportunity({ stageName: "Pipeline" }); const result = await processOpportunityAction( opp, { action: "acceptNew", payload: {} }, makeUser(), ); expect(result.success).toBe(false); expect(result.error).toBeTruthy(); }); test("rejects terminal status for non-reopen actions", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.Won, stageName: "Optima", }); const result = await processOpportunityAction( opp, { action: "acceptNew", payload: {} }, makeUser(), ); expect(result.success).toBe(false); }); test("routes acceptNew correctly", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew, stageName: "Optima", }); const result = await processOpportunityAction( opp, { action: "acceptNew", payload: {} }, makeUser(), ); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.New); }); test("calls refreshFromCW on success", async () => { const refreshFn = mock(() => Promise.resolve()); const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew, stageName: "Optima", refreshFromCW: refreshFn, }); await processOpportunityAction( opp, { action: "acceptNew", payload: {} }, makeUser(), ); expect(refreshFn).toHaveBeenCalledTimes(1); }); test("does not call refreshFromCW on failure", async () => { const refreshFn = mock(() => Promise.resolve()); const opp = makeOpportunity({ statusCwId: OpportunityStatus.Active, stageName: "Optima", refreshFromCW: refreshFn, }); await processOpportunityAction( opp, { action: "acceptNew", payload: {} }, makeUser(), ); expect(refreshFn).not.toHaveBeenCalled(); }); test("routes finalize to finalizeOpportunity with FINALIZE perm", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon, stageName: "Optima", }); const result = await processOpportunityAction( opp, { action: "finalize", payload: { outcome: "won", note: "Done" } }, makeUser(), ); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Won); }); test("routes finalize to transitionToPending without FINALIZE perm", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent, stageName: "Optima", }); const user = makeUser({ permissions: [WorkflowPermissions.WIN], }); const result = await processOpportunityAction( opp, { action: "finalize", payload: { outcome: "won", note: "Accepted" } }, user, ); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.PendingWon); }); test("reopen allowed from Canceled (skips terminal check)", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled, stageName: "Optima", }); const result = await processOpportunityAction( opp, { action: "reopen", payload: { note: "Reopening" } }, makeUser(), ); expect(result.success).toBe(true); expect(result.newStatusId).toBe(OpportunityStatus.Active); }); test("closes open workflow activities before transitioning", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew, stageName: "Optima", }); await processOpportunityAction( opp, { action: "acceptNew", payload: {} }, makeUser(), ); // closeOpenWorkflowActivities calls fetchByOpportunityDirect expect(mockFetchByOpportunityDirect).toHaveBeenCalledTimes(1); }); test("refreshFromCW failure does not fail the workflow", async () => { const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew, stageName: "Optima", refreshFromCW: mock(() => Promise.reject(new Error("CW down"))), }); const result = await processOpportunityAction( opp, { action: "acceptNew", payload: {} }, makeUser(), ); // Transition itself should still succeed expect(result.success).toBe(true); }); });