6d935e7180
- 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
248 lines
7.5 KiB
TypeScript
248 lines
7.5 KiB
TypeScript
/**
|
|
* CW Endpoint Validator
|
|
*
|
|
* Validates that all ConnectWise API endpoints used by the application
|
|
* are reachable and respond correctly. Uses the same axios instance
|
|
* and credentials as the running app.
|
|
*
|
|
* Usage: bun ./utils/validateCwEndpoints.ts
|
|
*/
|
|
|
|
import { connectWiseApi } from "../src/constants";
|
|
import { prisma } from "../src/constants";
|
|
|
|
interface EndpointTest {
|
|
name: string;
|
|
method: "get" | "post" | "patch" | "delete";
|
|
url: string;
|
|
/** If true, a 404 is treated as "endpoint exists but resource doesn't" — still a pass. */
|
|
allow404?: boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build test list — some require real IDs from the database
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function buildTestList(): Promise<EndpointTest[]> {
|
|
// Grab a sample opportunity from the DB to test with real IDs
|
|
const sampleOpp = await prisma.opportunity.findFirst({
|
|
where: { closedFlag: false },
|
|
select: { cwOpportunityId: true, companyId: true },
|
|
orderBy: { cwLastUpdated: "desc" },
|
|
});
|
|
|
|
const sampleCompany = await prisma.company.findFirst({
|
|
select: { cw_CompanyId: true },
|
|
});
|
|
|
|
const oppId = sampleOpp?.cwOpportunityId ?? 1;
|
|
const companyId = sampleCompany?.cw_CompanyId ?? 1;
|
|
|
|
return [
|
|
// ── Core counts (lightweight, always work) ──────────────────────────
|
|
{
|
|
name: "Opportunities count",
|
|
method: "get",
|
|
url: "/sales/opportunities/count",
|
|
},
|
|
{
|
|
name: "Activities count",
|
|
method: "get",
|
|
url: "/sales/activities/count",
|
|
},
|
|
{
|
|
name: "Companies count",
|
|
method: "get",
|
|
url: "/company/companies/count",
|
|
},
|
|
{
|
|
name: "Members count",
|
|
method: "get",
|
|
url: "/system/members/count",
|
|
},
|
|
{
|
|
name: "Catalog count",
|
|
method: "get",
|
|
url: "/procurement/catalog/count",
|
|
},
|
|
|
|
// ── Paginated list endpoints ────────────────────────────────────────
|
|
{
|
|
name: "Opportunities list (page 1, size 1)",
|
|
method: "get",
|
|
url: "/sales/opportunities?page=1&pageSize=1",
|
|
},
|
|
{
|
|
name: "Activities list (page 1, size 1)",
|
|
method: "get",
|
|
url: "/sales/activities?page=1&pageSize=1",
|
|
},
|
|
{
|
|
name: "Companies list (page 1, size 1)",
|
|
method: "get",
|
|
url: "/company/companies?page=1&pageSize=1",
|
|
},
|
|
{
|
|
name: "Members list (page 1, size 1)",
|
|
method: "get",
|
|
url: "/system/members?page=1&pageSize=1",
|
|
},
|
|
{
|
|
name: "Catalog list (page 1, size 1)",
|
|
method: "get",
|
|
url: "/procurement/catalog?page=1&pageSize=1",
|
|
},
|
|
{
|
|
name: "User-defined fields (page 1)",
|
|
method: "get",
|
|
url: "/system/userDefinedFields?pageSize=1",
|
|
},
|
|
|
|
// ── Single-resource fetches (need real IDs) ─────────────────────────
|
|
{
|
|
name: `Opportunity #${oppId}`,
|
|
method: "get",
|
|
url: `/sales/opportunities/${oppId}`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Opportunity #${oppId} forecast`,
|
|
method: "get",
|
|
url: `/sales/opportunities/${oppId}/forecast`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Opportunity #${oppId} notes`,
|
|
method: "get",
|
|
url: `/sales/opportunities/${oppId}/notes`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Opportunity #${oppId} contacts`,
|
|
method: "get",
|
|
url: `/sales/opportunities/${oppId}/contacts`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Activities for opp #${oppId}`,
|
|
method: "get",
|
|
url: `/sales/activities/count?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Procurement products for opp #${oppId}`,
|
|
method: "get",
|
|
url: `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Company #${companyId}`,
|
|
method: "get",
|
|
url: `/company/companies/${companyId}`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Company #${companyId} sites`,
|
|
method: "get",
|
|
url: `/company/companies/${companyId}/sites?pageSize=1`,
|
|
allow404: true,
|
|
},
|
|
{
|
|
name: `Company #${companyId} configurations`,
|
|
method: "get",
|
|
url: `/company/configurations?conditions=${encodeURIComponent(`company/id=${companyId}`)}&pageSize=1`,
|
|
allow404: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
console.log(
|
|
"╔══════════════════════════════════════════════════════════════╗",
|
|
);
|
|
console.log(
|
|
"║ ConnectWise API Endpoint Validator ║",
|
|
);
|
|
console.log(
|
|
"╚══════════════════════════════════════════════════════════════╝",
|
|
);
|
|
console.log();
|
|
console.log(`Base URL: ${connectWiseApi.defaults.baseURL}`);
|
|
console.log(`Timeout: ${connectWiseApi.defaults.timeout ?? "none"}ms`);
|
|
console.log();
|
|
|
|
const tests = await buildTestList();
|
|
let passed = 0;
|
|
let failed = 0;
|
|
let warned = 0;
|
|
|
|
for (const test of tests) {
|
|
const start = performance.now();
|
|
try {
|
|
const response = await connectWiseApi.request({
|
|
method: test.method,
|
|
url: test.url,
|
|
timeout: 30_000, // Use a generous timeout for validation
|
|
});
|
|
const elapsed = Math.round(performance.now() - start);
|
|
const statusTag =
|
|
elapsed > 5000 ? `⚠️ SLOW (${elapsed}ms)` : `${elapsed}ms`;
|
|
console.log(` ✅ ${test.name} — ${response.status} [${statusTag}]`);
|
|
if (elapsed > 5000) warned++;
|
|
passed++;
|
|
} catch (err: any) {
|
|
const elapsed = Math.round(performance.now() - start);
|
|
if (err?.isAxiosError) {
|
|
const status = err.response?.status;
|
|
const code = err.code;
|
|
|
|
if (status === 404 && test.allow404) {
|
|
console.log(
|
|
` ⚠️ ${test.name} — 404 (resource not found, endpoint OK) [${elapsed}ms]`,
|
|
);
|
|
warned++;
|
|
continue;
|
|
}
|
|
|
|
if (code === "ECONNABORTED") {
|
|
console.log(` ❌ ${test.name} — TIMEOUT after ${elapsed}ms`);
|
|
} else if (code === "ECONNREFUSED") {
|
|
console.log(
|
|
` ❌ ${test.name} — CONNECTION REFUSED (CW server down?)`,
|
|
);
|
|
} else if (status) {
|
|
console.log(
|
|
` ❌ ${test.name} — HTTP ${status} [${elapsed}ms]: ${err.response?.data?.message ?? err.message}`,
|
|
);
|
|
} else {
|
|
console.log(
|
|
` ❌ ${test.name} — ${code ?? err.message} [${elapsed}ms]`,
|
|
);
|
|
}
|
|
} else {
|
|
console.log(` ❌ ${test.name} — ${err.message} [${elapsed}ms]`);
|
|
}
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log();
|
|
console.log("─".repeat(64));
|
|
console.log(
|
|
` Results: ${passed} passed, ${warned} warnings, ${failed} failed (${tests.length} total)`,
|
|
);
|
|
console.log("─".repeat(64));
|
|
|
|
await prisma.$disconnect();
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("Fatal error:", err.message);
|
|
process.exit(1);
|
|
});
|