322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* 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([]);
|
|
});
|
|
});
|