feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage
This commit is contained in:
@@ -0,0 +1,934 @@
|
||||
/**
|
||||
* 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,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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> = {}): WorkflowUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
cwMemberId: 10,
|
||||
permissions: ["*"], // all permissions by default
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOpportunity(overrides: Record<string, any> = {}): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user