/** * 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 { // 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); });