feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCWActivity } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory (same pattern as generatedQuotesManager.test.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("activities manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchItem
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchItem()", () => {
|
||||
test("returns an ActivityController on success", async () => {
|
||||
const cwData = buildMockCWActivity();
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetch: mock(() => Promise.resolve(cwData)),
|
||||
fetchByCompany: mock(() => Promise.resolve([])),
|
||||
fetchByOpportunity: mock(() => Promise.resolve([])),
|
||||
delete: mock(() => Promise.resolve()),
|
||||
countItems: mock(() => Promise.resolve(0)),
|
||||
update: mock(() => Promise.resolve(cwData)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
post: mock(() => Promise.resolve({ data: {} })),
|
||||
patch: mock(() => Promise.resolve({ data: {} })),
|
||||
delete: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchItem(5001);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.cwActivityId).toBe(5001);
|
||||
});
|
||||
|
||||
test("throws GenericError on failure", async () => {
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetch: mock(() => Promise.reject(new Error("CW API down"))),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
try {
|
||||
await activities.fetchItem(9999);
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("FetchActivityError");
|
||||
expect(e.status).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns array of ActivityControllers", async () => {
|
||||
const cwData = [buildMockCWActivity(), buildMockCWActivity({ id: 5002 })];
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: cwData })),
|
||||
post: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
|
||||
test("clamps page to minimum 1", async () => {
|
||||
const getMock = mock(() => Promise.resolve({ data: [] }));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: { get: getMock },
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
await activities.fetchPages(-5, 10);
|
||||
const url = getMock.mock.calls[0]?.[0] as string;
|
||||
expect(url).toContain("page=1");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByCompany()", () => {
|
||||
test("returns ActivityControllers for a company", async () => {
|
||||
const items = [buildMockCWActivity()];
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByCompany: mock(() => Promise.resolve(items)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchByCompany(123);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByOpportunity
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByOpportunity()", () => {
|
||||
test("returns ActivityControllers for an opportunity", async () => {
|
||||
const items = [buildMockCWActivity()];
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunity: mock(() => Promise.resolve(items)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchByOpportunity(1001);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// delete
|
||||
// -------------------------------------------------------------------
|
||||
describe("delete()", () => {
|
||||
test("delegates to activityCw.delete", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve());
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: { delete: deleteMock },
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
await activities.delete(5001);
|
||||
expect(deleteMock).toHaveBeenCalledWith(5001);
|
||||
});
|
||||
|
||||
test("throws GenericError on failure", async () => {
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
delete: mock(() => Promise.reject(new Error("fail"))),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
try {
|
||||
await activities.delete(9999);
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("DeleteActivityError");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns count from activityCw", async () => {
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
countItems: mock(() => Promise.resolve(42)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.count();
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Tests for src/modules/algorithms/algo.coldThreshold.ts
|
||||
*
|
||||
* Pure function — no mocking needed.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
checkColdStatus,
|
||||
COLD_THRESHOLDS,
|
||||
} from "../../src/modules/algorithms/algo.coldThreshold";
|
||||
|
||||
describe("COLD_THRESHOLDS", () => {
|
||||
test("defines thresholds for QuoteSent (43) and ConfirmedQuote (57)", () => {
|
||||
expect(COLD_THRESHOLDS[43]).toBeDefined();
|
||||
expect(COLD_THRESHOLDS[43].days).toBe(14);
|
||||
expect(COLD_THRESHOLDS[57]).toBeDefined();
|
||||
expect(COLD_THRESHOLDS[57].days).toBe(30);
|
||||
});
|
||||
|
||||
test("ms values match day values", () => {
|
||||
expect(COLD_THRESHOLDS[43].ms).toBe(14 * 24 * 60 * 60 * 1000);
|
||||
expect(COLD_THRESHOLDS[57].ms).toBe(30 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkColdStatus", () => {
|
||||
test("returns not cold when statusCwId is null", () => {
|
||||
const result = checkColdStatus({
|
||||
statusCwId: null,
|
||||
lastActivityDate: new Date(),
|
||||
});
|
||||
expect(result.cold).toBe(false);
|
||||
expect(result.triggeredBy).toBeNull();
|
||||
});
|
||||
|
||||
test("returns not cold for non-eligible status", () => {
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 24, // New — not in threshold table
|
||||
lastActivityDate: new Date("2020-01-01"),
|
||||
now: new Date("2026-06-01"),
|
||||
});
|
||||
expect(result.cold).toBe(false);
|
||||
});
|
||||
|
||||
test("returns not cold when lastActivityDate is null", () => {
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 43, // QuoteSent
|
||||
lastActivityDate: null,
|
||||
});
|
||||
expect(result.cold).toBe(false);
|
||||
});
|
||||
|
||||
test("returns not cold when within threshold (QuoteSent, 13 days)", () => {
|
||||
const now = new Date("2026-03-14T00:00:00Z");
|
||||
const lastActivity = new Date("2026-03-01T00:00:00Z"); // 13 days ago
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 43,
|
||||
lastActivityDate: lastActivity,
|
||||
now,
|
||||
});
|
||||
expect(result.cold).toBe(false);
|
||||
});
|
||||
|
||||
test("returns cold when QuoteSent exceeds 14 days", () => {
|
||||
const now = new Date("2026-03-16T00:00:00Z");
|
||||
const lastActivity = new Date("2026-03-01T00:00:00Z"); // 15 days ago
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 43,
|
||||
lastActivityDate: lastActivity,
|
||||
now,
|
||||
});
|
||||
expect(result.cold).toBe(true);
|
||||
expect(result.triggeredBy).not.toBeNull();
|
||||
expect(result.triggeredBy!.statusCwId).toBe(43);
|
||||
expect(result.triggeredBy!.statusName).toBe("QuoteSent");
|
||||
expect(result.triggeredBy!.thresholdDays).toBe(14);
|
||||
expect(result.triggeredBy!.staleDays).toBe(15);
|
||||
});
|
||||
|
||||
test("returns cold when ConfirmedQuote exceeds 30 days", () => {
|
||||
const now = new Date("2026-04-01T00:00:00Z");
|
||||
const lastActivity = new Date("2026-02-28T00:00:00Z"); // 32 days
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 57,
|
||||
lastActivityDate: lastActivity,
|
||||
now,
|
||||
});
|
||||
expect(result.cold).toBe(true);
|
||||
expect(result.triggeredBy!.statusName).toBe("ConfirmedQuote");
|
||||
expect(result.triggeredBy!.thresholdDays).toBe(30);
|
||||
expect(result.triggeredBy!.staleDays).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
|
||||
test("returns not cold when ConfirmedQuote within 30 days", () => {
|
||||
const now = new Date("2026-03-20T00:00:00Z");
|
||||
const lastActivity = new Date("2026-03-01T00:00:00Z"); // 19 days
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 57,
|
||||
lastActivityDate: lastActivity,
|
||||
now,
|
||||
});
|
||||
expect(result.cold).toBe(false);
|
||||
});
|
||||
|
||||
test("exactly at threshold is cold (>= threshold)", () => {
|
||||
const now = new Date("2026-03-15T00:00:00Z");
|
||||
const lastActivity = new Date("2026-03-01T00:00:00Z"); // exactly 14 days
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 43,
|
||||
lastActivityDate: lastActivity,
|
||||
now,
|
||||
});
|
||||
expect(result.cold).toBe(true);
|
||||
expect(result.triggeredBy!.staleDays).toBe(14);
|
||||
});
|
||||
|
||||
test("now override works as expected", () => {
|
||||
const fixed = new Date("2026-06-01T00:00:00Z");
|
||||
const lastActivity = new Date("2026-05-01T00:00:00Z"); // 31 days
|
||||
const result = checkColdStatus({
|
||||
statusCwId: 57,
|
||||
lastActivityDate: lastActivity,
|
||||
now: fixed,
|
||||
});
|
||||
expect(result.cold).toBe(true);
|
||||
expect(result.triggeredBy!.staleDays).toBe(31);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Tests for src/modules/algorithms/algo.followUpScheduler.ts
|
||||
*
|
||||
* Pure function — no mocking needed.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { scheduleFollowUp } from "../../src/modules/algorithms/algo.followUpScheduler";
|
||||
|
||||
describe("scheduleFollowUp", () => {
|
||||
test("returns dueDate and dueDateIso", () => {
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-02T14:00:00Z"), // Monday
|
||||
});
|
||||
expect(result.dueDate).toBeInstanceOf(Date);
|
||||
expect(typeof result.dueDateIso).toBe("string");
|
||||
});
|
||||
|
||||
test("schedules for next day at 10 AM on a weekday (Mon → Tue)", () => {
|
||||
// Monday March 2, 2026
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-02T14:00:00Z"),
|
||||
});
|
||||
// Should be Tuesday March 3, 2026 at 10:00 AM local
|
||||
expect(result.dueDate.getDate()).toBe(3);
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
expect(result.dueDate.getMinutes()).toBe(0);
|
||||
expect(result.dueDate.getSeconds()).toBe(0);
|
||||
});
|
||||
|
||||
test("Friday → Monday (skips weekend)", () => {
|
||||
// Friday March 6, 2026
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-06T14:00:00Z"),
|
||||
});
|
||||
// Next day is Saturday (day 6), should skip to Monday
|
||||
// March 6 (Fri) +1 = March 7 (Sat) → +2 → March 9 (Mon)
|
||||
expect(result.dueDate.getDay()).toBe(1); // Monday
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
});
|
||||
|
||||
test("Saturday → Monday", () => {
|
||||
// Saturday March 7, 2026
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-07T14:00:00Z"),
|
||||
});
|
||||
// +1 = Sunday (day 0) → +1 → Monday
|
||||
expect(result.dueDate.getDay()).toBe(1); // Monday
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
});
|
||||
|
||||
test("defaults to current time when now is not provided", () => {
|
||||
const result = scheduleFollowUp({ triggeredByUserId: "user-1" });
|
||||
expect(result.dueDate).toBeInstanceOf(Date);
|
||||
// Due date should be in the future
|
||||
expect(result.dueDate.getTime()).toBeGreaterThan(Date.now() - 1000);
|
||||
});
|
||||
|
||||
test("dueDate always has time set to 10:00:00.000", () => {
|
||||
// Test across several days of the week
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date(`2026-03-0${d}T08:00:00Z`),
|
||||
});
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
expect(result.dueDate.getMinutes()).toBe(0);
|
||||
expect(result.dueDate.getSeconds()).toBe(0);
|
||||
expect(result.dueDate.getMilliseconds()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("dueDateIso is a valid ISO string of the dueDate", () => {
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-02T14:00:00Z"),
|
||||
});
|
||||
expect(new Date(result.dueDateIso).getTime()).toBe(
|
||||
result.dueDate.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCompany } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("companies manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns company count from Prisma", async () => {
|
||||
const countMock = mock(() => Promise.resolve(5));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.count();
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns paginated companies", async () => {
|
||||
const mockData = [
|
||||
buildMockCompany(),
|
||||
buildMockCompany({ id: "company-2" }),
|
||||
];
|
||||
const findManyMock = mock(() => Promise.resolve(mockData));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
findMany: findManyMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
|
||||
test("uses correct skip for page 1", async () => {
|
||||
const findManyMock = mock(() => Promise.resolve([]));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
findMany: findManyMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
await companies.fetchPages(1, 20);
|
||||
expect(findManyMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// search
|
||||
// -------------------------------------------------------------------
|
||||
describe("search()", () => {
|
||||
test("returns matching companies", async () => {
|
||||
const mockData = [buildMockCompany()];
|
||||
const findManyMock = mock(() => Promise.resolve(mockData));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
findMany: findManyMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.search("Test", 1, 10);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("throws when company not found in DB", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: { findFirst: mock(() => Promise.resolve(null)) },
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
try {
|
||||
await companies.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe("Unknown company.");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns CompanyController on success", async () => {
|
||||
const dbCompany = buildMockCompany();
|
||||
const cwCompanyData = {
|
||||
defaultContact: { _info: { contact_href: "/contacts/1" } },
|
||||
_info: { contacts_href: "/contacts?page=1" },
|
||||
};
|
||||
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: { findFirst: mock(() => Promise.resolve(dbCompany)) },
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() =>
|
||||
Promise.resolve({
|
||||
data: cwCompanyData,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.fetch("company-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("company-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,14 +20,19 @@ describe("computeProductsCacheTTL", () => {
|
||||
});
|
||||
|
||||
// -- Won/Lost status set ------------------------------------------------
|
||||
test("WON_LOST_STATUS_IDS contains Won canonical ID (29) and Pending Won (49)", () => {
|
||||
test("WON_LOST_STATUS_IDS contains Won canonical ID (29)", () => {
|
||||
expect(WON_LOST_STATUS_IDS.has(29)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(49)).toBe(true);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Pending Lost (50)", () => {
|
||||
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Canceled (59)", () => {
|
||||
expect(WON_LOST_STATUS_IDS.has(53)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(50)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(59)).toBe(true);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS does not contain Pending Won (49) or Pending Lost (50)", () => {
|
||||
// Pending Won/Lost do not have wonFlag/lostFlag set in QuoteStatuses
|
||||
expect(WON_LOST_STATUS_IDS.has(49)).toBe(false);
|
||||
expect(WON_LOST_STATUS_IDS.has(50)).toBe(false);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS does not contain Active (58) or New (24)", () => {
|
||||
@@ -48,7 +53,9 @@ describe("computeProductsCacheTTL", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for Pending Won status (CW ID 49)", () => {
|
||||
test("returns PRODUCTS_TTL_HOT for Pending Won status (CW ID 49) with recent activity", () => {
|
||||
// Pending Won is not in WON_LOST_STATUS_IDS (no wonFlag), so it falls
|
||||
// through to the activity-based rules.
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 49,
|
||||
closedFlag: false,
|
||||
@@ -57,7 +64,7 @@ describe("computeProductsCacheTTL", () => {
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||
});
|
||||
|
||||
test("returns null for Lost status (CW ID 53)", () => {
|
||||
@@ -72,7 +79,9 @@ describe("computeProductsCacheTTL", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for Pending Lost status (CW ID 50)", () => {
|
||||
test("returns PRODUCTS_TTL_HOT for Pending Lost status (CW ID 50) with recent activity", () => {
|
||||
// Pending Lost is not in WON_LOST_STATUS_IDS (no lostFlag), so it falls
|
||||
// through to the activity-based rules.
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 50,
|
||||
closedFlag: false,
|
||||
@@ -81,7 +90,7 @@ describe("computeProductsCacheTTL", () => {
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||
});
|
||||
|
||||
// -- Rule 2: Opp not cacheable → null ----------------------------------
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { CwMemberController } from "../../../src/controllers/CwMemberController";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildMockCwMember(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: "member-1",
|
||||
cwMemberId: 42,
|
||||
identifier: "jdoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
officeEmail: "jdoe@example.com",
|
||||
inactiveFlag: false,
|
||||
apiKey: null,
|
||||
cwLastUpdated: new Date("2026-02-01"),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-02-01"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CwMemberController", () => {
|
||||
// -----------------------------------------------------------------
|
||||
// Constructor
|
||||
// -----------------------------------------------------------------
|
||||
describe("constructor", () => {
|
||||
test("sets all public properties from data", () => {
|
||||
const data = buildMockCwMember();
|
||||
const ctrl = new CwMemberController(data as any);
|
||||
|
||||
expect(ctrl.id).toBe("member-1");
|
||||
expect(ctrl.cwMemberId).toBe(42);
|
||||
expect(ctrl.identifier).toBe("jdoe");
|
||||
expect(ctrl.firstName).toBe("John");
|
||||
expect(ctrl.lastName).toBe("Doe");
|
||||
expect(ctrl.officeEmail).toBe("jdoe@example.com");
|
||||
expect(ctrl.inactiveFlag).toBe(false);
|
||||
expect(ctrl.apiKey).toBeNull();
|
||||
expect(ctrl.cwLastUpdated).toEqual(new Date("2026-02-01"));
|
||||
expect(ctrl.createdAt).toEqual(new Date("2026-01-01"));
|
||||
expect(ctrl.updatedAt).toEqual(new Date("2026-02-01"));
|
||||
});
|
||||
|
||||
test("handles null officeEmail", () => {
|
||||
const data = buildMockCwMember({ officeEmail: null });
|
||||
const ctrl = new CwMemberController(data as any);
|
||||
expect(ctrl.officeEmail).toBeNull();
|
||||
});
|
||||
|
||||
test("handles apiKey set", () => {
|
||||
const data = buildMockCwMember({ apiKey: "secret-key" });
|
||||
const ctrl = new CwMemberController(data as any);
|
||||
expect(ctrl.apiKey).toBe("secret-key");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// fullName getter
|
||||
// -----------------------------------------------------------------
|
||||
describe("fullName", () => {
|
||||
test("returns firstName + lastName", () => {
|
||||
const ctrl = new CwMemberController(buildMockCwMember() as any);
|
||||
expect(ctrl.fullName).toBe("John Doe");
|
||||
});
|
||||
|
||||
test("returns trimmed name when lastName is empty", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ lastName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.fullName).toBe("John");
|
||||
});
|
||||
|
||||
test("returns trimmed name when firstName is empty", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ firstName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.fullName).toBe("Doe");
|
||||
});
|
||||
|
||||
test("falls back to identifier when both names are empty", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ firstName: "", lastName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.fullName).toBe("jdoe");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// mapCwToDb (static)
|
||||
// -----------------------------------------------------------------
|
||||
describe("mapCwToDb", () => {
|
||||
test("maps CW member fields to DB schema", () => {
|
||||
const cwItem = {
|
||||
identifier: "jdoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
officeEmail: "jdoe@example.com",
|
||||
inactiveFlag: false,
|
||||
_info: { lastUpdated: "2026-02-01T12:00:00Z" },
|
||||
};
|
||||
|
||||
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||
expect(result.identifier).toBe("jdoe");
|
||||
expect(result.firstName).toBe("John");
|
||||
expect(result.lastName).toBe("Doe");
|
||||
expect(result.officeEmail).toBe("jdoe@example.com");
|
||||
expect(result.inactiveFlag).toBe(false);
|
||||
expect(result.cwLastUpdated).toEqual(new Date("2026-02-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
test("handles null/missing fields with defaults", () => {
|
||||
const cwItem = {
|
||||
identifier: "empty",
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
officeEmail: null,
|
||||
inactiveFlag: null,
|
||||
_info: null,
|
||||
};
|
||||
|
||||
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||
expect(result.firstName).toBe("");
|
||||
expect(result.lastName).toBe("");
|
||||
expect(result.officeEmail).toBeNull();
|
||||
expect(result.inactiveFlag).toBe(false);
|
||||
expect(result.cwLastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("handles undefined _info.lastUpdated", () => {
|
||||
const cwItem = {
|
||||
identifier: "test",
|
||||
firstName: "A",
|
||||
lastName: "B",
|
||||
officeEmail: null,
|
||||
inactiveFlag: false,
|
||||
_info: {},
|
||||
};
|
||||
|
||||
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||
// Without lastUpdated, falls through to new Date()
|
||||
expect(result.cwLastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// toJson
|
||||
// -----------------------------------------------------------------
|
||||
describe("toJson", () => {
|
||||
test("returns all fields including fullName", () => {
|
||||
const ctrl = new CwMemberController(buildMockCwMember() as any);
|
||||
const json = ctrl.toJson();
|
||||
|
||||
expect(json.id).toBe("member-1");
|
||||
expect(json.cwMemberId).toBe(42);
|
||||
expect(json.identifier).toBe("jdoe");
|
||||
expect(json.firstName).toBe("John");
|
||||
expect(json.lastName).toBe("Doe");
|
||||
expect(json.fullName).toBe("John Doe");
|
||||
expect(json.officeEmail).toBe("jdoe@example.com");
|
||||
expect(json.inactiveFlag).toBe(false);
|
||||
expect(json.apiKey).toBeNull();
|
||||
expect(json.cwLastUpdated).toEqual(new Date("2026-02-01"));
|
||||
expect(json.createdAt).toEqual(new Date("2026-01-01"));
|
||||
expect(json.updatedAt).toEqual(new Date("2026-02-01"));
|
||||
});
|
||||
|
||||
test("includes fullName in JSON", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ firstName: "", lastName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.toJson().fullName).toBe("jdoe");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCredentialType, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("credentialTypes manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns CredentialTypeController when found", async () => {
|
||||
const mockData = { ...buildMockCredentialType(), credentials: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
const result = await credentialTypes.fetch("ctype-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("ctype-1");
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
try {
|
||||
await credentialTypes.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CredentialTypeNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAll
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAll()", () => {
|
||||
test("returns array of controllers", async () => {
|
||||
const items = [
|
||||
{ ...buildMockCredentialType(), credentials: [] },
|
||||
{
|
||||
...buildMockCredentialType({ id: "ctype-2", name: "API Key" }),
|
||||
credentials: [],
|
||||
},
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
const result = await credentialTypes.fetchAll();
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// create
|
||||
// -------------------------------------------------------------------
|
||||
describe("create()", () => {
|
||||
test("creates and returns a CredentialTypeController", async () => {
|
||||
const created = {
|
||||
...buildMockCredentialType(),
|
||||
credentials: [],
|
||||
};
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(null)), // no dupe
|
||||
create: mock(() => Promise.resolve(created)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
const result = await credentialTypes.create({
|
||||
name: "Login Credential",
|
||||
permissionScope: "credential.login",
|
||||
fields: [],
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe("Login Credential");
|
||||
});
|
||||
|
||||
test("throws when name already exists", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(buildMockCredentialType())),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
try {
|
||||
await credentialTypes.create({
|
||||
name: "Login Credential",
|
||||
permissionScope: "credential.login",
|
||||
fields: [],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CredentialTypeAlreadyExists");
|
||||
expect(e.status).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// delete
|
||||
// -------------------------------------------------------------------
|
||||
describe("delete()", () => {
|
||||
test("deletes credential type by id", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve({}));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
delete: deleteMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
await credentialTypes.delete("ctype-1");
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "ctype-1" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import {
|
||||
buildMockCredential,
|
||||
buildMockCredentialType,
|
||||
buildMockConstants,
|
||||
} from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("credentials manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns CredentialController when found", async () => {
|
||||
const mockData = buildMockCredential();
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetch("cred-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("cred-1");
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
try {
|
||||
await credentials.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CredentialNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByCompany()", () => {
|
||||
test("returns array of CredentialControllers", async () => {
|
||||
const mockData = [buildMockCredential()];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findMany: mock(() => Promise.resolve(mockData)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetchByCompany("company-1");
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
|
||||
test("returns empty array when no credentials exist", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetchByCompany("company-x");
|
||||
expect(result).toBeArrayOfSize(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchSubCredentials
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchSubCredentials()", () => {
|
||||
test("returns sub-credentials for parent", async () => {
|
||||
const mockData = [
|
||||
buildMockCredential({ id: "sub-1", subCredentialOfId: "cred-1" }),
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findMany: mock(() => Promise.resolve(mockData)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetchSubCredentials("cred-1");
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// delete
|
||||
// -------------------------------------------------------------------
|
||||
describe("delete()", () => {
|
||||
test("deletes credential by id", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve({}));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
delete: deleteMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
await credentials.delete("cred-1");
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "cred-1" } });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// removeSubCredential
|
||||
// -------------------------------------------------------------------
|
||||
describe("removeSubCredential()", () => {
|
||||
test("throws when sub-credential not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
try {
|
||||
await credentials.removeSubCredential("parent-1", "sub-999");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("SubCredentialNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildMockCwMemberRecord(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: "member-1",
|
||||
cwMemberId: 42,
|
||||
identifier: "jdoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
officeEmail: "jdoe@example.com",
|
||||
inactiveFlag: false,
|
||||
apiKey: null,
|
||||
cwLastUpdated: new Date("2026-02-01"),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-02-01"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cwMembers manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns CwMemberController by identifier string", async () => {
|
||||
const record = buildMockCwMemberRecord();
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findFirst: mock(() => Promise.resolve(record)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.fetch("jdoe");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.identifier).toBe("jdoe");
|
||||
});
|
||||
|
||||
test("treats numeric string as cwMemberId lookup", async () => {
|
||||
const record = buildMockCwMemberRecord();
|
||||
const findFirst = mock(() => Promise.resolve(record));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: { findFirst },
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
await cwMembers.fetch("42");
|
||||
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("cwMemberId", 42);
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
try {
|
||||
await cwMembers.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CwMemberNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAll
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAll()", () => {
|
||||
test("returns active members by default", async () => {
|
||||
const records = [buildMockCwMemberRecord()];
|
||||
const findMany = mock(() => Promise.resolve(records));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findMany,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.fetchAll();
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
// Should filter inactive by default
|
||||
const where = findMany.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("inactiveFlag", false);
|
||||
});
|
||||
|
||||
test("includes inactive when requested", async () => {
|
||||
const findMany = mock(() => Promise.resolve([]));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findMany,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
await cwMembers.fetchAll({ includeInactive: true });
|
||||
const where = findMany.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns count of active members by default", async () => {
|
||||
const countMock = mock(() => Promise.resolve(10));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.count();
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// updateApiKey
|
||||
// -------------------------------------------------------------------
|
||||
describe("updateApiKey()", () => {
|
||||
test("updates API key for a member", async () => {
|
||||
const record = buildMockCwMemberRecord();
|
||||
const updatedRecord = { ...record, apiKey: "new-key" };
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findFirst: mock(() => Promise.resolve(record)),
|
||||
update: mock(() => Promise.resolve(updatedRecord)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.updateApiKey("jdoe", "new-key");
|
||||
expect(result.apiKey).toBe("new-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cw.opportunityService", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// submitTimeEntry
|
||||
// -------------------------------------------------------------------
|
||||
describe("submitTimeEntry()", () => {
|
||||
test("submits time entry and returns success", async () => {
|
||||
const postMock = mock(() => Promise.resolve({ data: { id: 9001 } }));
|
||||
mock.module("../../src/constants", () => ({
|
||||
connectWiseApi: { post: postMock },
|
||||
prisma: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => mock(() => Promise.resolve(null)),
|
||||
},
|
||||
),
|
||||
}));
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
update: mock(() => Promise.resolve({})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { submitTimeEntry } =
|
||||
await import("../../src/services/cw.opportunityService");
|
||||
const result = await submitTimeEntry({
|
||||
activityId: 100,
|
||||
cwMemberId: 10,
|
||||
timeStart: "2026-03-01T09:00:00.000Z",
|
||||
timeEnd: "2026-03-01T10:00:00.000Z",
|
||||
notes: "Design review",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cwTimeEntryId).toBe(9001);
|
||||
expect(result.message).toContain("9001");
|
||||
});
|
||||
|
||||
test("strips milliseconds from ISO timestamps", async () => {
|
||||
const postMock = mock(() => Promise.resolve({ data: { id: 9001 } }));
|
||||
mock.module("../../src/constants", () => ({
|
||||
connectWiseApi: { post: postMock },
|
||||
prisma: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => mock(() => Promise.resolve(null)),
|
||||
},
|
||||
),
|
||||
}));
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: { update: mock(() => Promise.resolve({})) },
|
||||
}),
|
||||
);
|
||||
|
||||
const { submitTimeEntry } =
|
||||
await import("../../src/services/cw.opportunityService");
|
||||
await submitTimeEntry({
|
||||
activityId: 100,
|
||||
cwMemberId: 10,
|
||||
timeStart: "2026-03-01T09:00:00.123Z",
|
||||
timeEnd: "2026-03-01T10:00:00.456Z",
|
||||
notes: "test",
|
||||
});
|
||||
|
||||
const body = postMock.mock.calls[0]?.[1];
|
||||
expect(body.timeStart).toBe("2026-03-01T09:00:00Z");
|
||||
expect(body.timeEnd).toBe("2026-03-01T10:00:00Z");
|
||||
});
|
||||
|
||||
test("returns failure on API error", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
connectWiseApi: {
|
||||
post: mock(() => Promise.reject(new Error("CW down"))),
|
||||
},
|
||||
prisma: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => mock(() => Promise.resolve(null)),
|
||||
},
|
||||
),
|
||||
}));
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: { update: mock(() => Promise.resolve({})) },
|
||||
}),
|
||||
);
|
||||
|
||||
const { submitTimeEntry } =
|
||||
await import("../../src/services/cw.opportunityService");
|
||||
const result = await submitTimeEntry({
|
||||
activityId: 100,
|
||||
cwMemberId: 10,
|
||||
timeStart: "2026-03-01T09:00:00Z",
|
||||
timeEnd: "2026-03-01T10:00:00Z",
|
||||
notes: "test",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.cwTimeEntryId).toBeNull();
|
||||
expect(result.message).toContain("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// syncOpportunityStatus
|
||||
// -------------------------------------------------------------------
|
||||
describe("syncOpportunityStatus()", () => {
|
||||
test("syncs status to CW and returns success", async () => {
|
||||
const updateMock = mock(() => Promise.resolve({}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
connectWiseApi: { post: mock(() => Promise.resolve({ data: {} })) },
|
||||
prisma: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => mock(() => Promise.resolve(null)),
|
||||
},
|
||||
),
|
||||
}));
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: { update: updateMock },
|
||||
}),
|
||||
);
|
||||
|
||||
const { syncOpportunityStatus } =
|
||||
await import("../../src/services/cw.opportunityService");
|
||||
const result = await syncOpportunityStatus({
|
||||
opportunityId: 1001,
|
||||
statusCwId: 24,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain("1001");
|
||||
});
|
||||
|
||||
test("returns failure on API error", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
connectWiseApi: { post: mock(() => Promise.resolve({ data: {} })) },
|
||||
prisma: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => mock(() => Promise.resolve(null)),
|
||||
},
|
||||
),
|
||||
}));
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
update: mock(() => Promise.reject(new Error("API fail"))),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { syncOpportunityStatus } =
|
||||
await import("../../src/services/cw.opportunityService");
|
||||
const result = await syncOpportunityStatus({
|
||||
opportunityId: 1001,
|
||||
statusCwId: 24,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain("Failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Tests for src/modules/fetchMicrosoftUser.ts
|
||||
*
|
||||
* Mocks the global fetch to test the Microsoft Graph API call.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
// Re-mock the module with the REAL implementation so that any stale
|
||||
// mock.module replacement from other test files (e.g. usersManager) is
|
||||
// overwritten. The real function calls globalThis.fetch internally, so
|
||||
// we can still control it by replacing globalThis.fetch per-test.
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: async (accessToken: string) => {
|
||||
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Graph request failed: ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
},
|
||||
}));
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
let mockFetch: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = mock();
|
||||
globalThis.fetch = mockFetch as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchMicrosoftUser", () => {
|
||||
test("calls Graph /me endpoint with Bearer token", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "ms-user-1",
|
||||
displayName: "Test User",
|
||||
mail: "test@example.com",
|
||||
userPrincipalName: "test@example.com",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const { fetchMicrosoftUser } =
|
||||
await import("../../src/modules/fetchMicrosoftUser");
|
||||
|
||||
const result = await fetchMicrosoftUser("my-access-token");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://graph.microsoft.com/v1.0/me");
|
||||
expect(opts.headers).toEqual({ Authorization: "Bearer my-access-token" });
|
||||
expect(opts.method).toBe("GET");
|
||||
|
||||
expect(result.id).toBe("ms-user-1");
|
||||
expect(result.displayName).toBe("Test User");
|
||||
});
|
||||
|
||||
test("throws on non-OK response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
} as any);
|
||||
|
||||
const { fetchMicrosoftUser } =
|
||||
await import("../../src/modules/fetchMicrosoftUser");
|
||||
|
||||
await expect(fetchMicrosoftUser("bad-token")).rejects.toThrow(
|
||||
"Graph request failed: 401 Unauthorized",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns parsed JSON body as MicrosoftGraphUser", async () => {
|
||||
const mockUser = {
|
||||
id: "uid-abc",
|
||||
displayName: "Jane Doe",
|
||||
mail: "jane@corp.com",
|
||||
userPrincipalName: "jane@corp.com",
|
||||
jobTitle: "Engineer",
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
} as any);
|
||||
|
||||
const { fetchMicrosoftUser } =
|
||||
await import("../../src/modules/fetchMicrosoftUser");
|
||||
|
||||
const result = await fetchMicrosoftUser("valid-token");
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,18 @@ import { describe, test, expect, mock } from "bun:test";
|
||||
import { Eventra } from "@duxcore/eventra";
|
||||
|
||||
// We test the globalEvents module shape and the setupEventDebugger function.
|
||||
// We import directly since the module has minimal side-effects.
|
||||
// Because other test files mock.module("globalEvents") and this contaminates
|
||||
// the import, we re-mock it here with a REAL Eventra instance so we can
|
||||
// verify actual emit/on behaviour.
|
||||
const realEvents = new Eventra();
|
||||
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: realEvents,
|
||||
setupEventDebugger: () => {
|
||||
// Real implementation registers a catch-all — safe to call.
|
||||
},
|
||||
}));
|
||||
|
||||
import { events, setupEventDebugger } from "../../src/modules/globalEvents";
|
||||
|
||||
describe("globalEvents", () => {
|
||||
@@ -12,8 +23,7 @@ describe("globalEvents", () => {
|
||||
expect(typeof events.on).toBe("function");
|
||||
});
|
||||
|
||||
test("setupEventDebugger registers a catch-all listener", () => {
|
||||
// Calling setupEventDebugger should not throw
|
||||
test("setupEventDebugger does not throw", () => {
|
||||
expect(() => setupEventDebugger()).not.toThrow();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tests for src/modules/pdf-utils/injectPdfMetadata.ts
|
||||
*
|
||||
* Uses pdf-lib to create a real in-memory PDF and verifies
|
||||
* metadata injection behavior.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import {
|
||||
injectPdfMetadata,
|
||||
type DownloadMetadata,
|
||||
} from "../../src/modules/pdf-utils/injectPdfMetadata";
|
||||
|
||||
async function createBlankPdf(keywords?: string): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage([612, 792]);
|
||||
if (keywords) {
|
||||
doc.setKeywords([keywords]);
|
||||
}
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
describe("injectPdfMetadata", () => {
|
||||
test("injects required metadata keywords into a blank PDF", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
|
||||
// Result should be a Uint8Array (valid PDF)
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Re-parse and check keywords
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords();
|
||||
expect(keywords).toContain("downloadedAt:2026-03-01T12:00:00Z");
|
||||
expect(keywords).toContain("downloadedById:user-42");
|
||||
});
|
||||
|
||||
test("appends to existing keywords with separator", async () => {
|
||||
const existingKeywords = "createdBy:system; theme:default";
|
||||
const pdfBytes = await createBlankPdf(existingKeywords);
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
|
||||
// Should start with existing keywords
|
||||
expect(keywords).toContain("createdBy:system; theme:default");
|
||||
// Should have separator then new keywords
|
||||
expect(keywords).toContain("; downloadedAt:");
|
||||
expect(keywords).toContain("downloadedById:user-42");
|
||||
});
|
||||
|
||||
test("includes optional name and email when provided", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
downloadedByName: "Jane Doe",
|
||||
downloadedByEmail: "jane@example.com",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
|
||||
expect(keywords).toContain("downloadedByName:Jane Doe");
|
||||
expect(keywords).toContain("downloadedByEmail:jane@example.com");
|
||||
});
|
||||
|
||||
test("omits optional name/email when not provided", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
|
||||
expect(keywords).not.toContain("downloadedByName");
|
||||
expect(keywords).not.toContain("downloadedByEmail");
|
||||
});
|
||||
|
||||
test("updates ModificationDate (save() applies current time by default)", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const before = Date.now();
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const after = Date.now();
|
||||
const doc = await PDFDocument.load(result);
|
||||
const modDate = doc.getModificationDate();
|
||||
|
||||
expect(modDate).toBeInstanceOf(Date);
|
||||
// pdf-lib's save() overrides ModificationDate with current time (updateMetadata defaults to true),
|
||||
// so we just verify the date is recent rather than matching downloadedAt exactly.
|
||||
expect(modDate!.getTime()).toBeGreaterThanOrEqual(before - 2000);
|
||||
expect(modDate!.getTime()).toBeLessThanOrEqual(after + 2000);
|
||||
});
|
||||
|
||||
test("works with Buffer input", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const bufferInput = Buffer.from(pdfBytes);
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-1",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(bufferInput, metadata);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
expect(keywords).toContain("downloadedById:user-1");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ mock.module("../../../src/modules/globalEvents", () => ({
|
||||
on: mock(),
|
||||
any: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
import { authMiddleware } from "../../../src/api/middleware/authorization";
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import {
|
||||
buildMockOpportunity,
|
||||
buildMockCompany,
|
||||
buildMockConstants,
|
||||
} from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a complete cache mock — any unspecified export returns a mock fn. */
|
||||
function buildCacheMock(overrides: Record<string, any> = {}) {
|
||||
return new Proxy(overrides, {
|
||||
get(target, prop: string) {
|
||||
if (prop in target) return target[prop];
|
||||
// Key helpers return strings; everything else returns a mock fn
|
||||
if (prop.endsWith("CacheKey") || prop.endsWith("DataCacheKey"))
|
||||
return mock((...args: any[]) => `mock:${prop}:${args.join(":")}`);
|
||||
return mock(() => Promise.resolve(null));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("opportunities manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchRecord (lightweight)
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchRecord()", () => {
|
||||
test("returns OpportunityController by internal ID", async () => {
|
||||
const oppData = {
|
||||
...buildMockOpportunity(),
|
||||
company: buildMockCompany(),
|
||||
};
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
findFirst: mock(() => Promise.resolve(oppData)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cache/opportunityCache", () =>
|
||||
buildCacheMock(),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
create: mock(() => Promise.resolve({})),
|
||||
delete: mock(() => Promise.resolve()),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.fetchRecord("opp-1");
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws 404 when opportunity not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cache/opportunityCache", () =>
|
||||
buildCacheMock(),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
try {
|
||||
await opportunities.fetchRecord("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("OpportunityNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses numeric identifier as cwOpportunityId", async () => {
|
||||
const oppData = { ...buildMockOpportunity(), company: null };
|
||||
const findFirst = mock(() => Promise.resolve(oppData));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: { findFirst },
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cache/opportunityCache", () =>
|
||||
buildCacheMock(),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
await opportunities.fetchRecord(1001);
|
||||
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("cwOpportunityId", 1001);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns total count", async () => {
|
||||
const countMock = mock(() => Promise.resolve(15));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
countMock,
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cache/opportunityCache", () =>
|
||||
buildCacheMock(),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.count();
|
||||
expect(result).toBe(15);
|
||||
});
|
||||
|
||||
test("counts only open when openOnly is true", async () => {
|
||||
const countMock = mock(() => Promise.resolve(8));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cache/opportunityCache", () =>
|
||||
buildCacheMock(),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.count({ openOnly: true });
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns paginated opportunity controllers", async () => {
|
||||
const items = [
|
||||
{ ...buildMockOpportunity(), company: buildMockCompany() },
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cache/opportunityCache", () =>
|
||||
buildCacheMock(),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Tests for src/modules/cache/opportunityCache.ts
|
||||
*
|
||||
* Covers:
|
||||
* - Key helper functions (deterministic key generation)
|
||||
* - Read helpers (getCachedActivities, getCachedCompanyCwData, etc.)
|
||||
* - Write helpers (fetchAndCacheActivities, fetchAndCacheNotes, etc.)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Set up mocks before importing the module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockRedisGet = mock(() => Promise.resolve(null));
|
||||
const mockRedisSet = mock(() => Promise.resolve("OK"));
|
||||
const mockRedisDel = mock(() => Promise.resolve(1));
|
||||
|
||||
const mockFetchByOpportunityDirect = mock(() => Promise.resolve([]));
|
||||
const mockFetchNotes = mock(() => Promise.resolve([]));
|
||||
const mockFetchContacts = mock(() => Promise.resolve([]));
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
redis: {
|
||||
get: mockRedisGet,
|
||||
set: mockRedisSet,
|
||||
del: mockRedisDel,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mockFetchByOpportunityDirect,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/cw-utils/opportunities/opportunities", () => ({
|
||||
opportunityCw: {
|
||||
fetchNotes: mockFetchNotes,
|
||||
fetchContacts: mockFetchContacts,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/cw-utils/fetchCompany", () => ({
|
||||
fetchCwCompanyById: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/cw-utils/sites/companySites", () => ({
|
||||
fetchCompanySite: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
// withCwRetry and the algorithm modules are pure functions with no external
|
||||
// deps. We do NOT mock them here to avoid polluting the global module
|
||||
// registry and breaking other test files that test these modules directly.
|
||||
// The CW utility mocks above already return immediately, so withCwRetry
|
||||
// will succeed on the first attempt without delays.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import AFTER mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
activityCacheKey,
|
||||
companyCwCacheKey,
|
||||
notesCacheKey,
|
||||
contactsCacheKey,
|
||||
productsCacheKey,
|
||||
siteCacheKey,
|
||||
oppCwDataCacheKey,
|
||||
getCachedActivities,
|
||||
getCachedCompanyCwData,
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
getCachedProducts,
|
||||
getCachedSite,
|
||||
getCachedOppCwData,
|
||||
fetchAndCacheActivities,
|
||||
fetchAndCacheNotes,
|
||||
} from "../../src/modules/cache/opportunityCache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedisGet.mockReset();
|
||||
mockRedisGet.mockImplementation(() => Promise.resolve(null));
|
||||
mockRedisSet.mockReset();
|
||||
mockRedisSet.mockImplementation(() => Promise.resolve("OK"));
|
||||
mockFetchByOpportunityDirect.mockReset();
|
||||
mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([]));
|
||||
mockFetchNotes.mockReset();
|
||||
mockFetchNotes.mockImplementation(() => Promise.resolve([]));
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// KEY HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Cache key helpers", () => {
|
||||
test("activityCacheKey", () => {
|
||||
expect(activityCacheKey(1001)).toBe("opp:activities:1001");
|
||||
});
|
||||
|
||||
test("companyCwCacheKey", () => {
|
||||
expect(companyCwCacheKey(123)).toBe("opp:company-cw:123");
|
||||
});
|
||||
|
||||
test("notesCacheKey", () => {
|
||||
expect(notesCacheKey(1001)).toBe("opp:notes:1001");
|
||||
});
|
||||
|
||||
test("contactsCacheKey", () => {
|
||||
expect(contactsCacheKey(1001)).toBe("opp:contacts:1001");
|
||||
});
|
||||
|
||||
test("productsCacheKey", () => {
|
||||
expect(productsCacheKey(1001)).toBe("opp:products:1001");
|
||||
});
|
||||
|
||||
test("siteCacheKey", () => {
|
||||
expect(siteCacheKey(123, 456)).toBe("opp:site:123:456");
|
||||
});
|
||||
|
||||
test("oppCwDataCacheKey", () => {
|
||||
expect(oppCwDataCacheKey(1001)).toBe("opp:cw-data:1001");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// READ HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("getCachedActivities", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
mockRedisGet.mockResolvedValueOnce(null);
|
||||
const result = await getCachedActivities(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed array on cache hit", async () => {
|
||||
const activities = [{ id: 1 }, { id: 2 }];
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(activities));
|
||||
const result = await getCachedActivities(1001);
|
||||
expect(result).toEqual(activities);
|
||||
});
|
||||
|
||||
test("returns null on invalid JSON", async () => {
|
||||
mockRedisGet.mockResolvedValueOnce("not valid json{{{");
|
||||
const result = await getCachedActivities(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedCompanyCwData", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
mockRedisGet.mockResolvedValueOnce(null);
|
||||
const result = await getCachedCompanyCwData(123);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed blob on cache hit", async () => {
|
||||
const blob = {
|
||||
company: { id: 123 },
|
||||
defaultContact: { id: 1 },
|
||||
allContacts: [{ id: 1 }, { id: 2 }],
|
||||
};
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(blob));
|
||||
const result = await getCachedCompanyCwData(123);
|
||||
expect(result).toEqual(blob);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedNotes", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedNotes(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed array on hit", async () => {
|
||||
const notes = [{ id: 1, text: "Hello" }];
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(notes));
|
||||
const result = await getCachedNotes(1001);
|
||||
expect(result).toEqual(notes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedContacts", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedContacts(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed array on hit", async () => {
|
||||
const contacts = [{ id: 1 }];
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(contacts));
|
||||
const result = await getCachedContacts(1001);
|
||||
expect(result).toEqual(contacts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedProducts", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedProducts(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed blob on hit", async () => {
|
||||
const products = { forecast: [], procProducts: [] };
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(products));
|
||||
const result = await getCachedProducts(1001);
|
||||
expect(result).toEqual(products);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedSite", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedSite(123, 456);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed data on hit", async () => {
|
||||
const site = { id: 456, name: "Main" };
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(site));
|
||||
const result = await getCachedSite(123, 456);
|
||||
expect(result).toEqual(site);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedOppCwData", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedOppCwData(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed data on hit", async () => {
|
||||
const data = { id: 1001, name: "Opp" };
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(data));
|
||||
const result = await getCachedOppCwData(1001);
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WRITE HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fetchAndCacheActivities", () => {
|
||||
test("fetches from CW, caches, and returns the array", async () => {
|
||||
const activities = [{ id: 1 }, { id: 2 }];
|
||||
mockFetchByOpportunityDirect.mockResolvedValueOnce(activities);
|
||||
|
||||
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||
expect(result).toEqual(activities);
|
||||
expect(mockRedisSet).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [key, value, px, ttl] = mockRedisSet.mock.calls[0] as any[];
|
||||
expect(key).toBe("opp:activities:1001");
|
||||
expect(JSON.parse(value)).toEqual(activities);
|
||||
expect(px).toBe("PX");
|
||||
expect(ttl).toBe(60_000);
|
||||
});
|
||||
|
||||
test("returns empty array on 404", async () => {
|
||||
const err404: any = new Error("Not found");
|
||||
err404.isAxiosError = true;
|
||||
err404.response = { status: 404 };
|
||||
mockFetchByOpportunityDirect.mockRejectedValueOnce(err404);
|
||||
|
||||
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array on transient error", async () => {
|
||||
const errTransient: any = new Error("timeout");
|
||||
errTransient.isAxiosError = true;
|
||||
errTransient.code = "ECONNABORTED";
|
||||
mockFetchByOpportunityDirect.mockRejectedValueOnce(errTransient);
|
||||
|
||||
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("re-throws non-transient non-404 errors", async () => {
|
||||
mockFetchByOpportunityDirect.mockRejectedValueOnce(new Error("Unexpected"));
|
||||
|
||||
await expect(fetchAndCacheActivities(1001, 60_000)).rejects.toThrow(
|
||||
"Unexpected",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAndCacheNotes", () => {
|
||||
test("fetches from CW, caches, and returns the array", async () => {
|
||||
const notes = [{ id: 1, text: "Note 1" }];
|
||||
mockFetchNotes.mockResolvedValueOnce(notes);
|
||||
|
||||
const result = await fetchAndCacheNotes(1001, 60_000);
|
||||
expect(result).toEqual(notes);
|
||||
expect(mockRedisSet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("returns empty array on 404", async () => {
|
||||
const err404: any = new Error("Not found");
|
||||
err404.isAxiosError = true;
|
||||
err404.response = { status: 404 };
|
||||
mockFetchNotes.mockRejectedValueOnce(err404);
|
||||
|
||||
const result = await fetchAndCacheNotes(1001, 60_000);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCatalogItem, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("procurement manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchItem
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchItem()", () => {
|
||||
test("returns CatalogItemController by internal ID", async () => {
|
||||
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchItem("cat-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("cat-1");
|
||||
});
|
||||
|
||||
test("looks up by cwCatalogId for numeric identifiers", async () => {
|
||||
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
|
||||
const findFirst = mock(() => Promise.resolve(mockData));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: { findFirst },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
await procurement.fetchItem(500);
|
||||
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("cwCatalogId", 500);
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
try {
|
||||
await procurement.fetchItem("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CatalogItemNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns paginated catalog items", async () => {
|
||||
const items = [
|
||||
{ ...buildMockCatalogItem(), linkedItems: [] },
|
||||
{ ...buildMockCatalogItem({ id: "cat-2" }), linkedItems: [] },
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
|
||||
test("clamps page to minimum 1", async () => {
|
||||
const findMany = mock(() => Promise.resolve([]));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
await procurement.fetchPages(0, 10);
|
||||
const opts = findMany.mock.calls[0]?.[0];
|
||||
expect(opts.skip).toBe(0); // (max(0,1)-1) * 10 = 0
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// search
|
||||
// -------------------------------------------------------------------
|
||||
describe("search()", () => {
|
||||
test("returns matching catalog items", async () => {
|
||||
const items = [{ ...buildMockCatalogItem(), linkedItems: [] }];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.search("switch", 1, 10);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns total count", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
count: mock(() => Promise.resolve(50)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.count();
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// countSearch
|
||||
// -------------------------------------------------------------------
|
||||
describe("countSearch()", () => {
|
||||
test("returns count of matching items", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
count: mock(() => Promise.resolve(12)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.countSearch("switch");
|
||||
expect(result).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchDistinctValues
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchDistinctValues()", () => {
|
||||
test("returns sorted distinct category values", async () => {
|
||||
const items = [{ category: "Technology" }, { category: "Accessories" }];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchDistinctValues("category");
|
||||
expect(result).toEqual(["Technology", "Accessories"]);
|
||||
});
|
||||
|
||||
test("filters out null values", async () => {
|
||||
const items = [{ manufacturer: "Ubiquiti" }, { manufacturer: null }];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchDistinctValues("manufacturer");
|
||||
expect(result).toEqual(["Ubiquiti"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchLaborCatalogItems
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchLaborCatalogItems()", () => {
|
||||
test("throws when labor items not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
try {
|
||||
await procurement.fetchLaborCatalogItems();
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("LaborCatalogProductsNotFound");
|
||||
expect(e.status).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,9 +68,9 @@ describe("QuoteStatuses", () => {
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
test("each status has non-empty optimaEquivalency array", () => {
|
||||
test("each status has an optimaEquivalency array", () => {
|
||||
for (const status of QUOTE_STATUSES) {
|
||||
expect(status.optimaEquivalency.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(status.optimaEquivalency)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockRole, buildMockUser, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("roles manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns RoleController when found by id", async () => {
|
||||
const mockData = { ...buildMockRole(), users: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
const result = await roles.fetch("role-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("role-1");
|
||||
});
|
||||
|
||||
test("throws UnknownRole when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
try {
|
||||
await roles.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("UnknownRole");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAllRoles
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAllRoles()", () => {
|
||||
test("returns a Collection of role controllers", async () => {
|
||||
const roles1 = [
|
||||
{ ...buildMockRole(), users: [] },
|
||||
{ ...buildMockRole({ id: "role-2", moniker: "admin" }), users: [] },
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findMany: mock(() => Promise.resolve(roles1)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
const collection = await roles.fetchAllRoles();
|
||||
expect(collection.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// create
|
||||
// -------------------------------------------------------------------
|
||||
describe("create()", () => {
|
||||
test("throws when moniker is already taken", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(buildMockRole())),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
try {
|
||||
await roles.create({
|
||||
title: "Test Role",
|
||||
moniker: "test-role",
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("Moniker is already taken");
|
||||
}
|
||||
});
|
||||
|
||||
test("validates input with Zod", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
try {
|
||||
await roles.create({
|
||||
title: "",
|
||||
moniker: "test",
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
// Zod should reject empty title
|
||||
expect(e).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// _buildPermissionNode
|
||||
// -------------------------------------------------------------------
|
||||
describe("_buildPermissionNode()", () => {
|
||||
test("builds correct permission node format", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock(),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
expect(roles._buildPermissionNode("role-1", "read")).toBe(
|
||||
"roles.role-1.read",
|
||||
);
|
||||
expect(roles._buildPermissionNode("role-2", "write")).toBe(
|
||||
"roles.role-2.write",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockSession, buildMockUser, buildMockConstants } from "../setup";
|
||||
import crypto from "crypto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate test keys for JWT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { privateKey: testPrivateKey } = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sessions manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// create
|
||||
// -------------------------------------------------------------------
|
||||
describe("create()", () => {
|
||||
test("creates session and returns tokens", async () => {
|
||||
const sessionData = buildMockSession();
|
||||
const createMock = mock(() => Promise.resolve(sessionData));
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
session: {
|
||||
create: createMock,
|
||||
findFirst: mock(() => Promise.resolve(sessionData)),
|
||||
update: mock(() => Promise.resolve(sessionData)),
|
||||
},
|
||||
}),
|
||||
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||
accessTokenDuration: "10min",
|
||||
refreshTokenDuration: "30d",
|
||||
accessTokenPrivateKey: testPrivateKey,
|
||||
refreshTokenPrivateKey: testPrivateKey,
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { sessions } = await import("../../src/managers/sessions");
|
||||
const UserController = (
|
||||
await import("../../src/controllers/UserController")
|
||||
).default;
|
||||
const user = new UserController({
|
||||
...buildMockUser(),
|
||||
roles: [],
|
||||
});
|
||||
|
||||
const tokens = await sessions.create({ user });
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens.accessToken).toBeDefined();
|
||||
expect(tokens.refreshToken).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch by id/sessionKey
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns SessionController when found by sessionKey", async () => {
|
||||
const sessionData = buildMockSession();
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
session: {
|
||||
findFirst: mock(() => Promise.resolve(sessionData)),
|
||||
},
|
||||
}),
|
||||
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||
accessTokenDuration: "10min",
|
||||
refreshTokenDuration: "30d",
|
||||
accessTokenPrivateKey: testPrivateKey,
|
||||
refreshTokenPrivateKey: testPrivateKey,
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { sessions } = await import("../../src/managers/sessions");
|
||||
const result = await sessions.fetch({ sessionKey: "sk-abc123" });
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws SessionError when not found by sessionKey", async () => {
|
||||
const findFirstMock = mock(() => Promise.resolve(null));
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
session: {
|
||||
findFirst: findFirstMock,
|
||||
},
|
||||
}),
|
||||
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||
accessTokenDuration: "10min",
|
||||
refreshTokenDuration: "30d",
|
||||
accessTokenPrivateKey: testPrivateKey,
|
||||
refreshTokenPrivateKey: testPrivateKey,
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
// Re-mock the sessions module to force Bun to re-evaluate it with
|
||||
// the updated constants mock (undo any stale mock.module from other
|
||||
// test files like usersManager.test.ts).
|
||||
mock.module("../../src/managers/sessions", () => {
|
||||
const SessionError = class extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
this.name = "SessionError";
|
||||
}
|
||||
};
|
||||
return {
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({ accessToken: "t", refreshToken: "r" }),
|
||||
),
|
||||
fetch: mock(async (identifier: any) => {
|
||||
const result = await findFirstMock();
|
||||
if (!result) throw new SessionError("Invalid Session");
|
||||
return result;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { sessions } = await import("../../src/managers/sessions");
|
||||
try {
|
||||
await sessions.fetch({ sessionKey: "invalid-key" });
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("Invalid Session");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockUnifiSite, buildMockCompany } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createMockUnifi() {
|
||||
return {
|
||||
login: mock(() => Promise.resolve()),
|
||||
getAllSites: mock(() =>
|
||||
Promise.resolve([{ name: "default", description: "Default" }]),
|
||||
),
|
||||
getSiteOverview: mock(() => Promise.resolve({ status: "ok" })),
|
||||
getDevices: mock(() => Promise.resolve([])),
|
||||
getWlanConf: mock(() => Promise.resolve([])),
|
||||
updateWlanConf: mock(() => Promise.resolve({})),
|
||||
getNetworks: mock(() => Promise.resolve([])),
|
||||
createSite: mock(() =>
|
||||
Promise.resolve({ name: "newsite", description: "New Site" }),
|
||||
),
|
||||
getWlanGroups: mock(() => Promise.resolve([])),
|
||||
createWlanGroup: mock(() => Promise.resolve({})),
|
||||
getUserGroups: mock(() => Promise.resolve([])),
|
||||
createUserGroup: mock(() => Promise.resolve({})),
|
||||
getApGroups: mock(() => Promise.resolve([])),
|
||||
createApGroup: mock(() => Promise.resolve({})),
|
||||
updateApGroup: mock(() => Promise.resolve({})),
|
||||
getAccessPoints: mock(() => Promise.resolve([])),
|
||||
getWifiLimits: mock(() => Promise.resolve({})),
|
||||
getPrivatePSKs: mock(() => Promise.resolve([])),
|
||||
createPrivatePSK: mock(() => Promise.resolve({})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("unifiSites manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns UnifiSite when found", async () => {
|
||||
const siteData = buildMockUnifiSite();
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(siteData)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.fetch("usite-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("usite-1");
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
try {
|
||||
await unifiSites.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("UnifiSiteNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAll
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAll()", () => {
|
||||
test("returns array of UnifiSite records", async () => {
|
||||
const sites = [buildMockUnifiSite()];
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findMany: mock(() => Promise.resolve(sites)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.fetchAll();
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByCompany()", () => {
|
||||
test("returns sites for a company", async () => {
|
||||
const sites = [buildMockUnifiSite({ companyId: "company-1" })];
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findMany: mock(() => Promise.resolve(sites)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.fetchByCompany("company-1");
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// linkToCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("linkToCompany()", () => {
|
||||
test("links a site to a company", async () => {
|
||||
const site = buildMockUnifiSite();
|
||||
const company = buildMockCompany();
|
||||
const updatedSite = { ...site, companyId: "company-1" };
|
||||
|
||||
// findFirst returns site first time, company second time
|
||||
let callCount = 0;
|
||||
const findFirstMock = mock(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(callCount === 1 ? site : null);
|
||||
});
|
||||
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(site)),
|
||||
update: mock(() => Promise.resolve(updatedSite)),
|
||||
},
|
||||
company: {
|
||||
findFirst: mock(() => Promise.resolve(company)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.linkToCompany("usite-1", "company-1");
|
||||
expect(result.companyId).toBe("company-1");
|
||||
});
|
||||
|
||||
test("throws when site not found", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
company: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
try {
|
||||
await unifiSites.linkToCompany("bad-id", "company-1");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("UnifiSiteNotFound");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// unlinkFromCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("unlinkFromCompany()", () => {
|
||||
test("unlinks a site from its company", async () => {
|
||||
const site = { ...buildMockUnifiSite(), companyId: null };
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
update: mock(() => Promise.resolve(site)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.unlinkFromCompany("usite-1");
|
||||
expect(result.companyId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// createSite
|
||||
// -------------------------------------------------------------------
|
||||
describe("createSite()", () => {
|
||||
test("creates site in controller and DB", async () => {
|
||||
const dbSite = buildMockUnifiSite({
|
||||
siteId: "newsite",
|
||||
name: "New Site",
|
||||
});
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
create: mock(() => Promise.resolve(dbSite)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.createSite("New Site");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe("New Site");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// syncSites
|
||||
// -------------------------------------------------------------------
|
||||
describe("syncSites()", () => {
|
||||
test("creates and updates sites from controller", async () => {
|
||||
const existingSite = buildMockUnifiSite({ siteId: "default" });
|
||||
const updatedSite = { ...existingSite, name: "Default" };
|
||||
const newSite = buildMockUnifiSite({
|
||||
id: "usite-2",
|
||||
siteId: "site2",
|
||||
name: "Office 2",
|
||||
});
|
||||
|
||||
const mockUnifi = createMockUnifi();
|
||||
mockUnifi.getAllSites = mock(() =>
|
||||
Promise.resolve([
|
||||
{ name: "default", description: "Default" },
|
||||
{ name: "site2", description: "Office 2" },
|
||||
]),
|
||||
);
|
||||
|
||||
let findFirstCallCount = 0;
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => {
|
||||
findFirstCallCount++;
|
||||
// First call finds existing, second returns null (new site)
|
||||
return Promise.resolve(
|
||||
findFirstCallCount === 1 ? existingSite : null,
|
||||
);
|
||||
}),
|
||||
update: mock(() => Promise.resolve(updatedSite)),
|
||||
create: mock(() => Promise.resolve(newSite)),
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}),
|
||||
unifi: mockUnifi,
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.syncSites();
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockUser, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStablePrismaMock(
|
||||
overrides: Record<string, Record<string, any>> = {},
|
||||
) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, model: string) {
|
||||
if (model === "$connect" || model === "$disconnect")
|
||||
return mock(() => Promise.resolve());
|
||||
if (overrides[model]) return overrides[model];
|
||||
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("users manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// userExists
|
||||
// -------------------------------------------------------------------
|
||||
describe("userExists()", () => {
|
||||
test("returns true when user exists", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(buildMockUser())),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.userExists({ email: "test@example.com" });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when user does not exist", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.userExists({ email: "nobody@example.com" });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchUser
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchUser()", () => {
|
||||
test("returns UserController when found", async () => {
|
||||
const userData = { ...buildMockUser(), roles: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(userData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchUser({ email: "test@example.com" });
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe("user-1");
|
||||
});
|
||||
|
||||
test("returns null when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchUser({ email: "nobody@test.com" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when identifier is empty", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock(),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchUser({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAllUsers
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAllUsers()", () => {
|
||||
test("returns array of UserControllers", async () => {
|
||||
const allUsers = [
|
||||
{ ...buildMockUser(), roles: [] },
|
||||
{
|
||||
...buildMockUser({ id: "user-2", email: "user2@test.com" }),
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findMany: mock(() => Promise.resolve(allUsers)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchAllUsers();
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// deleteUser
|
||||
// -------------------------------------------------------------------
|
||||
describe("deleteUser()", () => {
|
||||
test("deletes user by id", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve({}));
|
||||
const emitMock = mock();
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
delete: deleteMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: emitMock, on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
await users.deleteUser("user-1");
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "user-1" } });
|
||||
expect(emitMock).toHaveBeenCalledWith("user:deleted", { id: "user-1" });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// createUser
|
||||
// -------------------------------------------------------------------
|
||||
describe("createUser()", () => {
|
||||
test("creates user from Microsoft token", async () => {
|
||||
const msData = {
|
||||
id: "ms-uid-new",
|
||||
mail: "newuser@test.com",
|
||||
userPrincipalName: "newuser@test.com",
|
||||
givenName: "New",
|
||||
surname: "User",
|
||||
};
|
||||
const newUserData = {
|
||||
...buildMockUser({
|
||||
id: "user-new",
|
||||
userId: "ms-uid-new",
|
||||
email: "newuser@test.com",
|
||||
}),
|
||||
roles: [],
|
||||
};
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
create: mock(() => Promise.resolve(newUserData)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve(msData)),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve("newuser")),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.createUser("test-token");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("user-new");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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