feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage

This commit is contained in:
2026-03-09 02:56:08 -05:00
parent c0a4d4f919
commit f53b390e18
50 changed files with 8837 additions and 63 deletions
+321
View File
@@ -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([]);
});
});