/** * 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([]); }); });