feat: restructure sales, add PDF quote generation and WebSocket support
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user