feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user