feat: Redis opportunity cache, CW API retry/logging, adaptive TTLs
- Add Redis-backed opportunity cache with background refresh (30s interval) - Fix concurrency bug: use lazy thunks instead of eager promises for batching - Add withCwRetry utility with exponential backoff for transient CW errors - Add adaptive TTL algorithms (primary, sub-resource, products) based on opportunity activity - Add include query param on GET /sales/opportunities/:id (notes,contacts,products) - Add opt-in CW API logger (LOG_CW_API env var) with timestamped files in cw-api-logs/ - Add debug-scripts/analyze-cw-calls.py for API call analysis - Add computeSubResourceCacheTTL and computeProductsCacheTTL algorithms with tests - Increase CW API timeout from 15s to 30s - Unblock cache refresh from startup chain (remove await) - Prioritize recently updated opportunities in refresh cycle - Add CACHING.md documentation - Update API_ROUTES.md with caching details and include param - Update copilot instructions to require CACHING.md sync - Add dev:log script for CW API call logging during development
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
computeProductsCacheTTL,
|
||||
PRODUCTS_TTL_HOT,
|
||||
PRODUCTS_TTL_LAZY,
|
||||
WON_LOST_STATUS_IDS,
|
||||
} from "../../src/modules/algorithms/computeProductsCacheTTL";
|
||||
|
||||
const NOW = new Date("2026-03-02T12:00:00Z");
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("computeProductsCacheTTL", () => {
|
||||
// -- Constants ----------------------------------------------------------
|
||||
test("PRODUCTS_TTL_HOT is 15 seconds", () => {
|
||||
expect(PRODUCTS_TTL_HOT).toBe(15_000);
|
||||
});
|
||||
|
||||
test("PRODUCTS_TTL_LAZY is 30 minutes", () => {
|
||||
expect(PRODUCTS_TTL_LAZY).toBe(1_800_000);
|
||||
});
|
||||
|
||||
// -- Won/Lost status set ------------------------------------------------
|
||||
test("WON_LOST_STATUS_IDS contains Won canonical ID (29) and Pending Won (49)", () => {
|
||||
expect(WON_LOST_STATUS_IDS.has(29)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(49)).toBe(true);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Pending Lost (50)", () => {
|
||||
expect(WON_LOST_STATUS_IDS.has(53)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(50)).toBe(true);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS does not contain Active (58) or New (24)", () => {
|
||||
expect(WON_LOST_STATUS_IDS.has(58)).toBe(false);
|
||||
expect(WON_LOST_STATUS_IDS.has(24)).toBe(false);
|
||||
});
|
||||
|
||||
// -- Rule 1: Won/Lost/Pending Won/Lost → null --------------------------
|
||||
test("returns null for Won status (CW ID 29)", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 29,
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 2 * DAY_MS),
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for Pending Won status (CW ID 49)", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 49,
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for Lost status (CW ID 53)", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 53,
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 5 * DAY_MS),
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for Pending Lost status (CW ID 50)", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 50,
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// -- Rule 2: Opp not cacheable → null ----------------------------------
|
||||
test("returns null when opp is closed > 30 days (main cache null)", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 58, // Active — but closed flag overrides
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 60 * DAY_MS),
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// -- Rule 3: Updated within 3 days → 15s -------------------------------
|
||||
test("returns PRODUCTS_TTL_HOT when lastUpdated is within 3 days", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 58,
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||
});
|
||||
|
||||
test("returns PRODUCTS_TTL_HOT when lastUpdated is exactly 3 days ago", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 58,
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 3 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||
});
|
||||
|
||||
// -- Rule 4: Everything else → 30 min ----------------------------------
|
||||
test("returns PRODUCTS_TTL_LAZY when lastUpdated is > 3 days ago", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 58,
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 10 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(PRODUCTS_TTL_LAZY);
|
||||
});
|
||||
|
||||
test("returns PRODUCTS_TTL_LAZY when no lastUpdated is set", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 58,
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(PRODUCTS_TTL_LAZY);
|
||||
});
|
||||
|
||||
test("returns PRODUCTS_TTL_LAZY for recently-closed (within 30 days) non-won/lost", () => {
|
||||
// Edge case: closedFlag true, but status is not Won/Lost (unusual but possible)
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 56, // Internal Review (not won/lost)
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 10 * DAY_MS),
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 20 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(PRODUCTS_TTL_LAZY);
|
||||
});
|
||||
|
||||
// -- Rule priority: Won/Lost takes priority over recent activity --------
|
||||
test("Won status takes priority even with very recent lastUpdated", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 29,
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 1000), // 1 second ago
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// -- Null statusCwId (should not skip rule 1) ---------------------------
|
||||
test("null statusCwId falls through to other rules", () => {
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: null,
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
computeSubResourceCacheTTL,
|
||||
SUB_TTL_HIGH_ACTIVITY,
|
||||
SUB_TTL_MODERATE_ACTIVITY,
|
||||
SUB_TTL_LOW_ACTIVITY,
|
||||
} from "../../src/modules/algorithms/computeSubResourceCacheTTL";
|
||||
|
||||
const NOW = new Date("2026-03-02T12:00:00Z");
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("computeSubResourceCacheTTL", () => {
|
||||
// -- Rule 1a: closed > 30 days → null -----------------------------------
|
||||
test("returns null for records closed > 30 days ago", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 31 * DAY_MS),
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// -- Rule 1b: closed within 30 days → SUB_TTL_LOW_ACTIVITY ---------------
|
||||
test("returns SUB_TTL_LOW_ACTIVITY for recently-closed records", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 10 * DAY_MS),
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(SUB_TTL_LOW_ACTIVITY);
|
||||
});
|
||||
|
||||
// -- Rule 2: within 5 days → SUB_TTL_HIGH_ACTIVITY ----------------------
|
||||
test("returns SUB_TTL_HIGH_ACTIVITY when expectedCloseDate is within 5 days", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: new Date(NOW.getTime() + 2 * DAY_MS),
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(SUB_TTL_HIGH_ACTIVITY);
|
||||
});
|
||||
|
||||
test("returns SUB_TTL_HIGH_ACTIVITY when lastUpdated is within 5 days", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 3 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(SUB_TTL_HIGH_ACTIVITY);
|
||||
});
|
||||
|
||||
// -- Rule 3: within 14 days → SUB_TTL_MODERATE_ACTIVITY -----------------
|
||||
test("returns SUB_TTL_MODERATE_ACTIVITY when expectedCloseDate is within 14 days", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: new Date(NOW.getTime() + 10 * DAY_MS),
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(SUB_TTL_MODERATE_ACTIVITY);
|
||||
});
|
||||
|
||||
test("returns SUB_TTL_MODERATE_ACTIVITY when lastUpdated is within 14 days", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: new Date(NOW.getTime() - 8 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(SUB_TTL_MODERATE_ACTIVITY);
|
||||
});
|
||||
|
||||
// -- Rule 4: everything else → SUB_TTL_LOW_ACTIVITY ---------------------
|
||||
test("returns SUB_TTL_LOW_ACTIVITY for stale records", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: new Date(NOW.getTime() - 30 * DAY_MS),
|
||||
lastUpdated: new Date(NOW.getTime() - 30 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(SUB_TTL_LOW_ACTIVITY);
|
||||
});
|
||||
|
||||
test("returns SUB_TTL_LOW_ACTIVITY when no dates are set", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: false,
|
||||
closedDate: null,
|
||||
expectedCloseDate: null,
|
||||
lastUpdated: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBe(SUB_TTL_LOW_ACTIVITY);
|
||||
});
|
||||
|
||||
// -- TTL ordering -------------------------------------------------------
|
||||
test("SUB_TTL values are ordered correctly", () => {
|
||||
expect(SUB_TTL_HIGH_ACTIVITY).toBe(60_000);
|
||||
expect(SUB_TTL_MODERATE_ACTIVITY).toBe(120_000);
|
||||
expect(SUB_TTL_LOW_ACTIVITY).toBe(300_000);
|
||||
expect(SUB_TTL_HIGH_ACTIVITY).toBeLessThan(SUB_TTL_MODERATE_ACTIVITY);
|
||||
expect(SUB_TTL_MODERATE_ACTIVITY).toBeLessThan(SUB_TTL_LOW_ACTIVITY);
|
||||
});
|
||||
|
||||
// -- Closed flag takes priority ------------------------------------------
|
||||
test("closed flag takes priority over recent activity dates", () => {
|
||||
const result = computeSubResourceCacheTTL({
|
||||
closedFlag: true,
|
||||
closedDate: new Date(NOW.getTime() - 60 * DAY_MS),
|
||||
expectedCloseDate: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user