import { describe, test, expect, mock, beforeEach } from "bun:test"; import { buildMockOpportunity, buildMockCompany, buildMockConstants, } from "../setup"; // --------------------------------------------------------------------------- // Stable mock factory // --------------------------------------------------------------------------- function createStablePrismaMock( overrides: Record> = {}, ) { 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 with explicit named exports. * * Uses concrete properties instead of a Proxy so that Bun's ESM mock * resolution can discover every named export at module-link time * (some Bun versions do not enumerate Proxy keys for static imports). */ function buildCacheMock(overrides: Record = {}) { return { // Key helpers — use real prefixes so cross-file mock leaks don't // break opportunityCache.test.ts key assertions. activityCacheKey: mock((id: number) => `opp:activities:${id}`), companyCwCacheKey: mock((id: number) => `opp:company-cw:${id}`), notesCacheKey: mock((id: number) => `opp:notes:${id}`), contactsCacheKey: mock((id: number) => `opp:contacts:${id}`), productsCacheKey: mock((id: number) => `opp:products:${id}`), siteCacheKey: mock((a: number, b: number) => `opp:site:${a}:${b}`), oppCwDataCacheKey: mock((id: number) => `opp:cw-data:${id}`), // Read helpers getCachedActivities: mock(() => Promise.resolve(null)), getCachedCompanyCwData: mock(() => Promise.resolve(null)), getCachedNotes: mock(() => Promise.resolve(null)), getCachedContacts: mock(() => Promise.resolve(null)), getCachedProducts: mock(() => Promise.resolve(null)), getCachedSite: mock(() => Promise.resolve(null)), getCachedOppCwData: mock(() => Promise.resolve(null)), // Write / fetch helpers fetchAndCacheActivities: mock(() => Promise.resolve(null)), fetchAndCacheCompanyCwData: mock(() => Promise.resolve(null)), fetchAndCacheNotes: mock(() => Promise.resolve(null)), fetchAndCacheContacts: mock(() => Promise.resolve(null)), fetchAndCacheProducts: mock(() => Promise.resolve(null)), fetchAndCacheSite: mock(() => Promise.resolve(null)), fetchAndCacheOppCwData: mock(() => Promise.resolve(null)), // Invalidation helpers invalidateNotesCache: mock(() => Promise.resolve()), invalidateContactsCache: mock(() => Promise.resolve()), invalidateProductsCache: mock(() => Promise.resolve()), invalidateAllOpportunityCaches: mock(() => Promise.resolve()), // Background refresh refreshOpportunityCache: mock(() => Promise.resolve()), ...overrides, }; } // --------------------------------------------------------------------------- // 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); }); }); });