feat: restructure sales, add PDF quote generation and WebSocket support

This commit is contained in:
2026-03-06 23:25:37 -06:00
parent 4efca6cc53
commit 1907bb433b
73 changed files with 8115 additions and 170 deletions
@@ -0,0 +1,223 @@
import { describe, test, expect } from "bun:test";
import { CatalogItemController } from "../../../src/controllers/CatalogItemController";
import { buildMockCatalogItem } from "../../setup";
describe("CatalogItemController", () => {
// -------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------
describe("constructor", () => {
test("sets core identification fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.id).toBe("cat-1");
expect(ctrl.cwCatalogId).toBe(500);
expect(ctrl.identifier).toBe("USW-Pro-24");
});
test("sets name and description fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.name).toBe("UniFi Switch Pro 24");
expect(ctrl.description).toBe("24-port managed switch");
expect(ctrl.customerDescription).toBe("Enterprise switch");
expect(ctrl.internalNotes).toBeNull();
});
test("sets category and subcategory fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.category).toBe("Technology");
expect(ctrl.categoryCwId).toBe(18);
expect(ctrl.subcategory).toBe("Network-Switch");
expect(ctrl.subcategoryCwId).toBe(112);
});
test("sets manufacturer fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.manufacturer).toBe("Ubiquiti");
expect(ctrl.manufactureCwId).toBe(248);
expect(ctrl.partNumber).toBe("USW-Pro-24");
});
test("sets vendor fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.vendorName).toBe("Ubiquiti Inc");
expect(ctrl.vendorSku).toBe("USW-Pro-24");
expect(ctrl.vendorCwId).toBe(100);
});
test("sets financial fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.price).toBe(500.0);
expect(ctrl.cost).toBe(360.0);
});
test("sets boolean flags", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.inactive).toBe(false);
expect(ctrl.salesTaxable).toBe(true);
});
test("sets inventory fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.onHand).toBe(10);
});
test("sets timestamps", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.cwLastUpdated).toBeInstanceOf(Date);
expect(ctrl.createdAt).toBeInstanceOf(Date);
expect(ctrl.updatedAt).toBeInstanceOf(Date);
});
test("builds linked items recursively", () => {
const linked = buildMockCatalogItem({
id: "cat-2",
name: "Linked Item",
linkedItems: undefined,
});
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const items = ctrl.getLinkedItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe("cat-2");
expect(items[0]).toBeInstanceOf(CatalogItemController);
});
test("defaults to empty linked items when undefined", () => {
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: undefined }),
);
expect(ctrl.getLinkedItems()).toHaveLength(0);
});
test("handles null optional fields", () => {
const ctrl = new CatalogItemController(
buildMockCatalogItem({
description: null,
customerDescription: null,
identifier: null,
category: null,
categoryCwId: null,
subcategory: null,
subcategoryCwId: null,
manufacturer: null,
manufactureCwId: null,
partNumber: null,
vendorName: null,
vendorSku: null,
vendorCwId: null,
cwLastUpdated: null,
}),
);
expect(ctrl.description).toBeNull();
expect(ctrl.customerDescription).toBeNull();
expect(ctrl.identifier).toBeNull();
expect(ctrl.category).toBeNull();
expect(ctrl.manufacturer).toBeNull();
expect(ctrl.cwLastUpdated).toBeNull();
});
});
// -------------------------------------------------------------------
// getLinkedItems
// -------------------------------------------------------------------
describe("getLinkedItems()", () => {
test("returns empty array when no linked items", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.getLinkedItems()).toEqual([]);
});
test("returns array of CatalogItemController instances", () => {
const linked1 = buildMockCatalogItem({ id: "cat-2", name: "Item 2" });
const linked2 = buildMockCatalogItem({ id: "cat-3", name: "Item 3" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked1, linked2] }),
);
const items = ctrl.getLinkedItems();
expect(items).toHaveLength(2);
expect(items[0].name).toBe("Item 2");
expect(items[1].name).toBe("Item 3");
});
});
// -------------------------------------------------------------------
// toJson
// -------------------------------------------------------------------
describe("toJson()", () => {
test("returns all core fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.id).toBe("cat-1");
expect(json.cwCatalogId).toBe(500);
expect(json.identifier).toBe("USW-Pro-24");
expect(json.name).toBe("UniFi Switch Pro 24");
expect(json.description).toBe("24-port managed switch");
expect(json.customerDescription).toBe("Enterprise switch");
expect(json.internalNotes).toBeNull();
});
test("returns classification fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.category).toBe("Technology");
expect(json.categoryCwId).toBe(18);
expect(json.subcategory).toBe("Network-Switch");
expect(json.subcategoryCwId).toBe(112);
expect(json.manufacturer).toBe("Ubiquiti");
expect(json.manufactureCwId).toBe(248);
expect(json.partNumber).toBe("USW-Pro-24");
});
test("returns financial fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.price).toBe(500.0);
expect(json.cost).toBe(360.0);
});
test("returns boolean flags", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.inactive).toBe(false);
expect(json.salesTaxable).toBe(true);
});
test("returns timestamps", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.createdAt).toBeInstanceOf(Date);
expect(json.updatedAt).toBeInstanceOf(Date);
expect(json.cwLastUpdated).toBeInstanceOf(Date);
});
test("excludes linkedItems when includeLinkedItems not set", () => {
const linked = buildMockCatalogItem({ id: "cat-2" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson();
expect(json.linkedItems).toBeUndefined();
});
test("includes linkedItems when includeLinkedItems is true", () => {
const linked = buildMockCatalogItem({ id: "cat-2", name: "Linked" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson({ includeLinkedItems: true });
expect(json.linkedItems).toHaveLength(1);
expect(json.linkedItems[0].id).toBe("cat-2");
expect(json.linkedItems[0].name).toBe("Linked");
});
test("linked items toJson does not recursively include their linked items", () => {
const linked = buildMockCatalogItem({ id: "cat-2" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson({ includeLinkedItems: true });
// Nested linked items called without opts, so linkedItems is undefined
expect(json.linkedItems[0].linkedItems).toBeUndefined();
});
});
});
@@ -138,6 +138,40 @@ describe("ForecastProductController", () => {
});
});
// -------------------------------------------------------------------
// applyProcurementCustomFields
// -------------------------------------------------------------------
describe("applyProcurementCustomFields()", () => {
test("sets productNarrative from custom field id 46", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({
customFields: [{ id: 46, value: "Custom narrative text" }],
});
expect(ctrl.productNarrative).toBe("Custom narrative text");
});
test("does not overwrite productNarrative when field 46 is missing", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.productNarrative = "existing";
ctrl.applyProcurementCustomFields({
customFields: [{ id: 99, value: "other" }],
});
expect(ctrl.productNarrative).toBe("existing");
});
test("handles empty customFields array", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({ customFields: [] });
expect(ctrl.productNarrative).toBeNull();
});
test("handles undefined customFields", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({});
expect(ctrl.productNarrative).toBeNull();
});
});
// -------------------------------------------------------------------
// applyInventoryData
// -------------------------------------------------------------------
@@ -203,6 +237,67 @@ describe("ForecastProductController", () => {
});
expect(ctrl.cancellationType).toBe("partial");
});
test("effectiveQuantity returns full quantity when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
expect(ctrl.effectiveQuantity).toBe(5);
});
test("effectiveQuantity returns reduced quantity for partial cancel", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 });
expect(ctrl.effectiveQuantity).toBe(3);
});
test("effectiveQuantity returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveQuantity).toBe(0);
});
test("effectiveRevenue returns full revenue when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
expect(ctrl.effectiveRevenue).toBe(2500);
});
test("effectiveRevenue returns proportional revenue for partial cancel", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 });
expect(ctrl.effectiveRevenue).toBe(1500);
});
test("effectiveRevenue returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveRevenue).toBe(0);
});
test("effectiveCost returns full cost when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, cost: 1800 }),
);
expect(ctrl.effectiveCost).toBe(1800);
});
test("effectiveCost returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, cost: 1800 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveCost).toBe(0);
});
});
// -------------------------------------------------------------------
@@ -279,5 +374,24 @@ describe("ForecastProductController", () => {
expect(json.subNumber).toBe(0);
expect(json.cwLastUpdated).toBeInstanceOf(Date);
});
test("includes customerDescription and productNarrative", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ customerDescription: "Customer desc" }),
);
const json = ctrl.toJson();
expect(json.customerDescription).toBe("Customer desc");
expect(json.productNarrative).toBeNull();
});
test("includes effective* computed fields", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500, cost: 1800 }),
);
const json = ctrl.toJson();
expect(json.effectiveQuantity).toBe(5);
expect(json.effectiveRevenue).toBe(2500);
expect(json.effectiveCost).toBe(1800);
});
});
});
@@ -0,0 +1,171 @@
import { describe, test, expect } from "bun:test";
import { GeneratedQuoteController } from "../../../src/controllers/GeneratedQuoteController";
import {
buildMockGeneratedQuote,
buildMockOpportunity,
buildMockUser,
} from "../../setup";
describe("GeneratedQuoteController", () => {
// -------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------
describe("constructor", () => {
test("sets core identification fields", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.id).toBe("quote-1");
expect(ctrl.quoteFileName).toBe("Quote-TestOpp.pdf");
expect(ctrl.opportunityId).toBe("opp-1");
expect(ctrl.createdById).toBe("user-1");
});
test("sets quoteRegenData", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.quoteRegenData).toEqual({ theme: "default" });
});
test("sets quoteFile as Uint8Array", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.quoteFile).toBeInstanceOf(Uint8Array);
expect(ctrl.quoteFile.length).toBe(4);
});
test("sets timestamps", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.createdAt).toBeInstanceOf(Date);
expect(ctrl.updatedAt).toBeInstanceOf(Date);
});
test("wraps included opportunity in OpportunityController", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeDefined();
expect(json.opportunity.id).toBe("opp-1");
});
test("wraps included createdBy in UserController", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeDefined();
expect(json.createdBy.id).toBe("user-1");
});
test("sets _opportunity to null when opportunity not included", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: null }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeUndefined();
});
test("sets _createdBy to null when createdBy not included", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: null }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeUndefined();
});
test("handles null createdById", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdById: null }),
);
expect(ctrl.createdById).toBeNull();
});
});
// -------------------------------------------------------------------
// toJson
// -------------------------------------------------------------------
describe("toJson()", () => {
test("returns core fields by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.id).toBe("quote-1");
expect(json.quoteFileName).toBe("Quote-TestOpp.pdf");
expect(json.opportunityId).toBe("opp-1");
expect(json.createdById).toBe("user-1");
expect(json.createdAt).toBeInstanceOf(Date);
expect(json.updatedAt).toBeInstanceOf(Date);
});
test("excludes quoteFile by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.quoteFile).toBeUndefined();
});
test("excludes quoteRegenData by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.quoteRegenData).toBeUndefined();
});
test("includes quoteRegenData when requested", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({ includeRegenData: true });
expect(json.quoteRegenData).toEqual({ theme: "default" });
});
test("includes quoteFile as raw Uint8Array when includeFile is true", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({ includeFile: true });
expect(json.quoteFile).toBeInstanceOf(Uint8Array);
});
test("includes quoteFile as base64 when both flags set", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({
includeFile: true,
encodeFileAsBase64: true,
});
expect(typeof json.quoteFile).toBe("string");
// Should be valid base64
expect(() => Buffer.from(json.quoteFile, "base64")).not.toThrow();
});
test("excludes opportunity when not requested", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson();
expect(json.opportunity).toBeUndefined();
});
test("includes opportunity when requested and available", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeDefined();
expect(json.opportunity.name).toBe("Test Opportunity");
});
test("excludes createdBy when not requested", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson();
expect(json.createdBy).toBeUndefined();
});
test("includes createdBy when requested and available", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeDefined();
expect(json.createdBy.name).toBe("Test User");
});
});
});
+241
View File
@@ -0,0 +1,241 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { Hono } from "hono";
import { apiResponse } from "../../src/modules/api-utils/apiResponse";
import type { ContentfulStatusCode } from "hono/utils/http-status";
/**
* Tests for the CW callback route handler.
*
* We import the route handler and mount it on a Hono app to test via
* the app.request() convenience method.
*/
// We need to test the internal helper functions. Since they are not
// exported, we test them through the route handler's observable behavior.
import callbackRoute from "../../src/api/cw/callback";
describe("CW callback route handler", () => {
let app: Hono;
beforeEach(() => {
app = new Hono();
// Replicate the error handling from server.ts
app.onError((err, c) => {
if ((err as any).status) {
const body = apiResponse.error(err);
return c.json(body, body.status as ContentfulStatusCode);
}
return c.json(apiResponse.internalError(), 500);
});
app.route("/", callbackRoute);
// Clear the env var before each test
delete (process.env as Record<string, any>).CW_CALLBACK_SECRET;
});
// -------------------------------------------------------------------
// Secret validation
// -------------------------------------------------------------------
test("rejects when secret does not match CW_CALLBACK_SECRET", async () => {
process.env.CW_CALLBACK_SECRET = "correct-secret";
const res = await app.request("/callback/wrong-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toContain("Invalid callback secret");
});
test("accepts when secret matches CW_CALLBACK_SECRET", async () => {
process.env.CW_CALLBACK_SECRET = "correct-secret";
const res = await app.request("/callback/correct-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Action: "updated", ID: 123 }),
});
expect(res.status).toBe(200);
});
test("accepts any secret when CW_CALLBACK_SECRET is not configured", async () => {
const res = await app.request("/callback/any-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Action: "created" }),
});
expect(res.status).toBe(200);
});
// -------------------------------------------------------------------
// Resource validation
// -------------------------------------------------------------------
test("accepts 'opportunity' resource", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("opportunity");
});
test("accepts 'ticket' resource", async () => {
const res = await app.request("/callback/test/ticket", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("ticket");
});
test("accepts 'company' resource", async () => {
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("company");
});
test("accepts 'activity' resource", async () => {
const res = await app.request("/callback/test/activity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("activity");
});
test("rejects invalid resource type", async () => {
const res = await app.request("/callback/test/invalidtype", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
// Zod validation should fail
expect(res.status).toBeGreaterThanOrEqual(400);
});
// -------------------------------------------------------------------
// Body parsing
// -------------------------------------------------------------------
test("parses JSON body fields", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "updated",
Type: "opportunity",
ID: 42,
MemberId: "jroberts",
MessageId: "msg-123",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.action).toBe("updated");
expect(body.data.summary.type).toBe("opportunity");
expect(body.data.summary.id).toBe(42);
expect(body.data.summary.memberId).toBe("jroberts");
expect(body.data.summary.messageId).toBe("msg-123");
});
test("parses Entity field from JSON string", async () => {
const entity = {
CompanyName: "Acme Corp",
StatusName: "Active",
UpdatedBy: "admin",
};
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "updated",
Entity: JSON.stringify(entity),
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.entitySummary).toBe("Acme Corp");
expect(body.data.summary.entityStatus).toBe("Active");
expect(body.data.summary.entityUpdatedBy).toBe("admin");
});
test("handles Entity as inline object", async () => {
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "created",
Entity: { CompanyName: "Direct Corp", Status: "New" },
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.entitySummary).toBe("Direct Corp");
expect(body.data.summary.entityStatus).toBe("New");
});
test("returns secretValidated field based on env presence", async () => {
delete (process.env as Record<string, any>).CW_CALLBACK_SECRET;
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(body.data.secretValidated).toBe(false);
process.env.CW_CALLBACK_SECRET = "secret";
const res2 = await app.request("/callback/secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body2 = await res2.json();
expect(body2.data.secretValidated).toBe(true);
});
test("returns receivedAt timestamp", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(body.data.receivedAt).toBeDefined();
// Should be a valid ISO date string
expect(new Date(body.data.receivedAt).toISOString()).toBe(
body.data.receivedAt,
);
});
test("handles non-JSON body gracefully", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "this is not json",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toBeNull();
});
test("handles empty body gracefully", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toBeNull();
});
});
+184
View File
@@ -0,0 +1,184 @@
import { describe, test, expect, mock } from "bun:test";
import { attachCwConcurrencyLimiter } from "../../src/modules/cw-utils/cwConcurrencyLimiter";
/**
* Build a minimal fake Axios instance with interceptor registration.
* Collect registered interceptors so we can invoke them in tests.
*/
function createMockAxios() {
const requestHandlers: Array<(config: any) => any> = [];
const responseSuccessHandlers: Array<(res: any) => any> = [];
const responseErrorHandlers: Array<(err: any) => any> = [];
return {
interceptors: {
request: {
use(fn: (config: any) => any) {
requestHandlers.push(fn);
},
},
response: {
use(onSuccess: (res: any) => any, onError: (err: any) => any) {
responseSuccessHandlers.push(onSuccess);
responseErrorHandlers.push(onError);
},
},
},
_requestHandlers: requestHandlers,
_responseSuccessHandlers: responseSuccessHandlers,
_responseErrorHandlers: responseErrorHandlers,
};
}
describe("attachCwConcurrencyLimiter", () => {
test("attaches request and response interceptors", () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any);
expect(api._requestHandlers).toHaveLength(1);
expect(api._responseSuccessHandlers).toHaveLength(1);
expect(api._responseErrorHandlers).toHaveLength(1);
});
test("request interceptor resolves immediately when under limit", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
const config = { url: "/test" };
const result = await api._requestHandlers[0](config);
expect(result).toEqual(config);
});
test("response success interceptor passes through response", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
// Acquire a slot first
await api._requestHandlers[0]({});
const response = { data: "ok", status: 200 };
const result = api._responseSuccessHandlers[0](response);
expect(result).toEqual(response);
});
test("response error interceptor rejects with the error and releases slot", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
// Acquire a slot
await api._requestHandlers[0]({});
const error = new Error("fail");
try {
await api._responseErrorHandlers[0](error);
expect(true).toBe(false); // should not reach
} catch (e) {
expect(e).toBe(error);
}
});
test("queues requests when at max concurrency", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
// First request acquires the single slot
await api._requestHandlers[0]({ id: 1 });
// Second request should be queued (not resolved yet)
let secondResolved = false;
const secondPromise = api._requestHandlers[0]({ id: 2 }).then(
(config: any) => {
secondResolved = true;
return config;
},
);
// Give the event loop a tick — second should still be pending
await new Promise((r) => setTimeout(r, 10));
expect(secondResolved).toBe(false);
// Release the first slot via response handler
api._responseSuccessHandlers[0]({ status: 200 });
// Now the second should resolve
const result = await secondPromise;
expect(secondResolved).toBe(true);
expect(result).toEqual({ id: 2 });
});
test("multiple requests under limit all proceed immediately", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 3);
const results = await Promise.all([
api._requestHandlers[0]({ id: 1 }),
api._requestHandlers[0]({ id: 2 }),
api._requestHandlers[0]({ id: 3 }),
]);
expect(results).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
});
test("FIFO ordering: queued requests resolve in order", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
// Fill the single slot
await api._requestHandlers[0]({ id: 1 });
const order: number[] = [];
const p2 = api._requestHandlers[0]({ id: 2 }).then(() => order.push(2));
const p3 = api._requestHandlers[0]({ id: 3 }).then(() => order.push(3));
// Release slot → should wake request 2
api._responseSuccessHandlers[0]({});
await p2;
// Release again → should wake request 3
api._responseSuccessHandlers[0]({});
await p3;
expect(order).toEqual([2, 3]);
});
test("error release also unblocks queued requests", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
await api._requestHandlers[0]({ id: 1 });
let secondResolved = false;
const secondPromise = api._requestHandlers[0]({ id: 2 }).then(() => {
secondResolved = true;
});
// Release via error path
try {
await api._responseErrorHandlers[0](new Error("fail"));
} catch {}
await secondPromise;
expect(secondResolved).toBe(true);
});
test("defaults to max 6 concurrency", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any); // default max = 6
// 6 requests should all proceed immediately
const promises = [];
for (let i = 0; i < 6; i++) {
promises.push(api._requestHandlers[0]({ id: i }));
}
const results = await Promise.all(promises);
expect(results).toHaveLength(6);
// 7th should queue
let seventhResolved = false;
const seventh = api._requestHandlers[0]({ id: 7 }).then(() => {
seventhResolved = true;
});
await new Promise((r) => setTimeout(r, 10));
expect(seventhResolved).toBe(false);
// Release one to unblock
api._responseSuccessHandlers[0]({});
await seventh;
expect(seventhResolved).toBe(true);
});
});
+261
View File
@@ -0,0 +1,261 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { GeneratedQuoteController } from "../../src/controllers/GeneratedQuoteController";
import {
buildMockGeneratedQuote,
buildMockOpportunity,
buildMockUser,
} from "../setup";
/**
* The Proxy-based prisma mock in setup.ts creates a fresh mock() for every
* property access, so we cannot use `(prisma.x.findFirst as any).mockReturnValueOnce()`
* because the manager's import access gets a different mock.
*
* Instead, we mock the entire constants module per-test with stable mock functions.
*/
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy(
{},
{
get() {
return mock(() => Promise.resolve(null));
},
},
);
},
},
);
}
describe("generatedQuotes manager", () => {
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns a GeneratedQuoteController when found", async () => {
const mockData = buildMockGeneratedQuote();
const findFirst = mock(() => Promise.resolve(mockData));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst },
}),
}));
// Re-import to pick up the fresh mock
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetch("quote-1");
expect(result).toBeInstanceOf(GeneratedQuoteController);
expect(result.id).toBe("quote-1");
});
test("throws GenericError with 404 when not found", async () => {
const findFirst = mock(() => Promise.resolve(null));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("GeneratedQuoteNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchByOpportunity
// -------------------------------------------------------------------
describe("fetchByOpportunity()", () => {
test("returns array of GeneratedQuoteController", async () => {
const rows = [
buildMockGeneratedQuote({ id: "q-1" }),
buildMockGeneratedQuote({ id: "q-2" }),
];
const findMany = mock(() => Promise.resolve(rows));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByOpportunity("opp-1");
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(GeneratedQuoteController);
expect(result[0].id).toBe("q-1");
});
test("returns empty array when none found", async () => {
const findMany = mock(() => Promise.resolve([]));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByOpportunity("opp-999");
expect(result).toEqual([]);
});
});
// -------------------------------------------------------------------
// fetchByCreator
// -------------------------------------------------------------------
describe("fetchByCreator()", () => {
test("returns array of GeneratedQuoteController", async () => {
const rows = [buildMockGeneratedQuote({ id: "q-1" })];
const findMany = mock(() => Promise.resolve(rows));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByCreator("user-1");
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(GeneratedQuoteController);
});
});
// -------------------------------------------------------------------
// create
// -------------------------------------------------------------------
describe("create()", () => {
test("throws 404 when opportunity not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.create({
quoteRegenData: {},
quoteFile: Buffer.from("pdf"),
quoteFileName: "test.pdf",
opportunityId: "nonexistent-opp",
createdById: "user-1",
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("OpportunityNotFound");
expect(e.status).toBe(404);
}
});
test("throws 404 when user not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve({ id: "opp-1" })),
},
user: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.create({
quoteRegenData: {},
quoteFile: Buffer.from("pdf"),
quoteFileName: "test.pdf",
opportunityId: "opp-1",
createdById: "nonexistent-user",
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("UserNotFound");
expect(e.status).toBe(404);
}
});
test("creates and returns GeneratedQuoteController", async () => {
const mockQuote = buildMockGeneratedQuote();
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve({ id: "opp-1" })),
},
user: { findFirst: mock(() => Promise.resolve({ id: "user-1" })) },
generatedQuotes: { create: mock(() => Promise.resolve(mockQuote)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.create({
quoteRegenData: { theme: "default" },
quoteFile: Buffer.from("pdf-content"),
quoteFileName: "Quote.pdf",
opportunityId: "opp-1",
createdById: "user-1",
});
expect(result).toBeInstanceOf(GeneratedQuoteController);
expect(result.id).toBe("quote-1");
});
});
// -------------------------------------------------------------------
// delete
// -------------------------------------------------------------------
describe("delete()", () => {
test("throws 404 when quote not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.delete("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("GeneratedQuoteNotFound");
expect(e.status).toBe(404);
}
});
test("deletes successfully when quote exists", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: {
findFirst: mock(() => Promise.resolve({ id: "quote-1" })),
delete: mock(() => Promise.resolve(undefined)),
},
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
await expect(generatedQuotes.delete("quote-1")).resolves.toBeUndefined();
});
});
});
+8
View File
@@ -32,9 +32,17 @@ describe("PermissionNodes", () => {
expect(PERMISSION_NODES).toHaveProperty("global");
expect(PERMISSION_NODES).toHaveProperty("company");
expect(PERMISSION_NODES).toHaveProperty("credential");
expect(PERMISSION_NODES).toHaveProperty("credentialType");
expect(PERMISSION_NODES).toHaveProperty("sales");
expect(PERMISSION_NODES).toHaveProperty("procurement");
expect(PERMISSION_NODES).toHaveProperty("objectTypes");
expect(PERMISSION_NODES).toHaveProperty("permission");
expect(PERMISSION_NODES).toHaveProperty("role");
expect(PERMISSION_NODES).toHaveProperty("user");
expect(PERMISSION_NODES).toHaveProperty("uiNavigation");
expect(PERMISSION_NODES).toHaveProperty("adminUI");
expect(PERMISSION_NODES).toHaveProperty("cwCallbacks");
expect(PERMISSION_NODES).toHaveProperty("unifi");
});
test("each category has name, description, and permissions", () => {
+1
View File
@@ -61,6 +61,7 @@ describe("procurement manager", () => {
expect(typeof procurement.fetchDistinctValues).toBe("function");
expect(typeof procurement.linkItems).toBe("function");
expect(typeof procurement.unlinkItems).toBe("function");
expect(typeof procurement.fetchLaborCatalogItems).toBe("function");
});
test("fetchPages calls through without errors (mock absorbs)", async () => {
+160
View File
@@ -0,0 +1,160 @@
import { describe, test, expect } from "bun:test";
import { generateSecureValue } from "../../src/modules/credentials/generateSecureValue";
import { readSecureValue } from "../../src/modules/credentials/readSecureValue";
/**
* Tests for the secure value encryption/decryption round-trip.
*
* The test setup.ts mocks the constants module with a matching RSA key pair,
* so generateSecureValue (uses public key) and readSecureValue (uses private key)
* will work correctly together.
*/
describe("generateSecureValue", () => {
test("returns an object with encrypted and hash fields", () => {
const result = generateSecureValue("my-secret-password");
expect(result).toHaveProperty("encrypted");
expect(result).toHaveProperty("hash");
expect(typeof result.encrypted).toBe("string");
expect(typeof result.hash).toBe("string");
});
test("encrypted is a valid base64 string", () => {
const result = generateSecureValue("test-value");
expect(() => Buffer.from(result.encrypted, "base64")).not.toThrow();
const decoded = Buffer.from(result.encrypted, "base64");
expect(decoded.length).toBeGreaterThan(0);
});
test("hash follows BLAKE2s format", () => {
const result = generateSecureValue("test-value");
expect(result.hash).toMatch(/^BLAKE2s\$/);
const parts = result.hash.split("$");
expect(parts[0]).toBe("BLAKE2s");
expect(parts[1].length).toBeGreaterThan(0); // hex hash
});
test("different inputs produce different encrypted values", () => {
const a = generateSecureValue("password-a");
const b = generateSecureValue("password-b");
expect(a.encrypted).not.toBe(b.encrypted);
});
test("different inputs produce different hashes", () => {
const a = generateSecureValue("password-a");
const b = generateSecureValue("password-b");
expect(a.hash).not.toBe(b.hash);
});
test("encrypts empty string without error", () => {
const result = generateSecureValue("");
expect(result.encrypted.length).toBeGreaterThan(0);
expect(result.hash.length).toBeGreaterThan(0);
});
test("encrypts special characters", () => {
const result = generateSecureValue('p@$$w0rd!#%^&*(){}[]|\\:";<>?,./~`');
expect(result.encrypted.length).toBeGreaterThan(0);
});
test("encrypts Unicode content", () => {
const result = generateSecureValue("密码测试 🔐");
expect(result.encrypted.length).toBeGreaterThan(0);
});
});
describe("readSecureValue", () => {
test("decrypts a value encrypted by generateSecureValue", () => {
const original = "my-secret-password";
const { encrypted } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
test("decrypts empty string", () => {
const { encrypted } = generateSecureValue("");
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe("");
});
test("decrypts special characters", () => {
const original = 'p@$$w0rd!#%^&*(){}[]|\\:";<>?,./~`';
const { encrypted } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
test("decrypts Unicode content", () => {
const original = "密码测试 🔐";
const { encrypted } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
test("validates hash when provided and correct", () => {
const original = "test-value";
const { encrypted, hash } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted, hash);
expect(decrypted).toBe(original);
});
test("throws when hash validation fails", () => {
const original = "test-value";
const { encrypted } = generateSecureValue(original);
const badHash =
"BLAKE2s$0000000000000000000000000000000000000000000000000000000000000000$salt";
expect(() => readSecureValue(encrypted, badHash)).toThrow(
"Secure value hash validation failed",
);
});
test("throws GenericError on invalid encrypted content", () => {
try {
readSecureValue("not-valid-encrypted-data");
expect(true).toBe(false); // should not reach
} catch (e: any) {
expect(e.name).toBe("SecureValueDecryptionError");
expect(e.status).toBe(422);
}
});
test("throws GenericError with descriptive message on key mismatch", () => {
try {
readSecureValue("dGhpcyBpcyBub3QgZW5jcnlwdGVk"); // base64 but not RSA
expect(true).toBe(false);
} catch (e: any) {
expect(e.message).toContain("Unable to decrypt secure value");
expect(e.cause).toContain("RSA key mismatch");
}
});
test("skips hash validation when hash is not provided", () => {
const original = "no-hash-check";
const { encrypted } = generateSecureValue(original);
// Should not throw even though no hash is passed
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
});
describe("generateSecureValue + readSecureValue round-trip", () => {
test("round-trips various string types", () => {
const testValues = [
"simple",
"",
"a".repeat(100),
"line1\nline2\ttab",
'{"json": true}',
"null",
"undefined",
"0",
" leading-trailing ",
];
for (const original of testValues) {
const { encrypted, hash } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted, hash);
expect(decrypted).toBe(original);
}
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, test, expect } from "bun:test";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { signPermissions } from "../../src/modules/permission-utils/signPermissions";
// The test setup mocks the constants module with a test RSA key pair.
// signPermissions imports permissionsPrivateKey from constants, which
// is the test private key generated in setup.ts. We can verify with the
// corresponding test public key.
// Re-generate the same key pair used in setup.ts from constants mock:
// The mock uses _testPrivateKey/_testPublicKey, but we can decode the JWT
// to verify its contents without needing the public key directly.
describe("signPermissions", () => {
test("returns a string (JWT)", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: ["company.fetch.many"],
});
expect(typeof token).toBe("string");
expect(token.split(".")).toHaveLength(3); // header.payload.signature
});
test("JWT payload contains permissions array", () => {
const permissions = ["company.fetch.many", "credential.read"];
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions,
});
const decoded = jwt.decode(token) as any;
expect(decoded.permissions).toEqual(permissions);
});
test("JWT contains issuer claim", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: ["*"],
});
const decoded = jwt.decode(token, { complete: true }) as any;
expect(decoded.payload.iss).toBe("optima");
});
test("JWT contains subject claim", () => {
const token = signPermissions({
issuer: "optima",
subject: "role-abc",
permissions: ["*"],
});
const decoded = jwt.decode(token, { complete: true }) as any;
expect(decoded.payload.sub).toBe("role-abc");
});
test("JWT uses RS256 algorithm", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: [],
});
const decoded = jwt.decode(token, { complete: true }) as any;
expect(decoded.header.alg).toBe("RS256");
});
test("handles empty permissions array", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: [],
});
const decoded = jwt.decode(token) as any;
expect(decoded.permissions).toEqual([]);
});
test("handles large permissions arrays", () => {
const permsList = Array.from({ length: 100 }, (_, i) => `perm.${i}`);
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: permsList,
});
const decoded = jwt.decode(token) as any;
expect(decoded.permissions).toHaveLength(100);
expect(decoded.permissions[0]).toBe("perm.0");
expect(decoded.permissions[99]).toBe("perm.99");
});
});
+245
View File
@@ -0,0 +1,245 @@
import { describe, test, expect, mock } from "bun:test";
import { withCwRetry } from "../../src/modules/cw-utils/withCwRetry";
describe("withCwRetry", () => {
// -------------------------------------------------------------------
// Successful execution
// -------------------------------------------------------------------
test("returns result on first successful call", async () => {
const fn = mock(() => Promise.resolve("ok"));
const result = await withCwRetry(fn);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
test("returns result when call succeeds after transient failure", async () => {
let attempt = 0;
const fn = mock(() => {
attempt++;
if (attempt === 1) {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
return Promise.reject(err);
}
return Promise.resolve("recovered");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("recovered");
expect(fn).toHaveBeenCalledTimes(2);
});
// -------------------------------------------------------------------
// Retry on transient errors
// -------------------------------------------------------------------
test("retries on ECONNABORTED", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls < 3) {
const err: any = new Error("aborted");
err.isAxiosError = true;
err.code = "ECONNABORTED";
return Promise.reject(err);
}
return Promise.resolve("done");
});
const result = await withCwRetry(fn, { maxAttempts: 3, baseDelayMs: 1 });
expect(result).toBe("done");
expect(fn).toHaveBeenCalledTimes(3);
});
test("retries on ECONNRESET", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("reset");
err.isAxiosError = true;
err.code = "ECONNRESET";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on ECONNREFUSED", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("refused");
err.isAxiosError = true;
err.code = "ECONNREFUSED";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on ERR_NETWORK", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("network");
err.isAxiosError = true;
err.code = "ERR_NETWORK";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on ENETUNREACH", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("unreachable");
err.isAxiosError = true;
err.code = "ENETUNREACH";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on 5xx server errors", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("server error");
err.isAxiosError = true;
err.response = { status: 502 };
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on 500 status", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("internal");
err.isAxiosError = true;
err.response = { status: 500 };
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
// -------------------------------------------------------------------
// Non-retryable errors
// -------------------------------------------------------------------
test("does not retry on 4xx errors", async () => {
const err: any = new Error("not found");
err.isAxiosError = true;
err.response = { status: 404 };
const fn = mock(() => Promise.reject(err));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(1);
});
test("does not retry on 400 errors", async () => {
const err: any = new Error("bad request");
err.isAxiosError = true;
err.response = { status: 400 };
const fn = mock(() => Promise.reject(err));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(1);
});
test("does not retry on non-Axios errors", async () => {
const fn = mock(() => Promise.reject(new Error("generic")));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow(
"generic",
);
expect(fn).toHaveBeenCalledTimes(1);
});
test("does not retry on non-object errors", async () => {
const fn = mock(() => Promise.reject("string error"));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toBe(
"string error",
);
expect(fn).toHaveBeenCalledTimes(1);
});
// -------------------------------------------------------------------
// Max attempts exhausted
// -------------------------------------------------------------------
test("throws after maxAttempts exhausted", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(
withCwRetry(fn, { maxAttempts: 2, baseDelayMs: 1 }),
).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(2);
});
test("throws the last error when retries exhausted", async () => {
const err: any = new Error("persistent timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
try {
await withCwRetry(fn, { maxAttempts: 3, baseDelayMs: 1 });
expect(true).toBe(false); // should not reach
} catch (e: any) {
expect(e.message).toBe("persistent timeout");
}
});
// -------------------------------------------------------------------
// Options
// -------------------------------------------------------------------
test("defaults to 3 maxAttempts", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(3);
});
test("accepts custom maxAttempts", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(
withCwRetry(fn, { maxAttempts: 5, baseDelayMs: 1 }),
).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(5);
});
test("maxAttempts of 1 means no retries", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(
withCwRetry(fn, { maxAttempts: 1, baseDelayMs: 1 }),
).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(1);
});
});