d7b374f8ab
New features: - ActivityController and manager for CW sales activities (CRUD) - ForecastProductController for opportunity forecast/product lines - CW member cache with dual-layer (in-memory + Redis) resolution - Catalog category/subcategory/ecosystem taxonomy module - Quote statuses type definitions with CW mapping - User-defined fields (UDF) module with cache and event refresh - Company sites CW module with serialization - Procurement manager filters (category, ecosystem, manufacturer, price, stock) - Opportunity notes CRUD and product line management via CW API - Opportunity type definitions endpoint Updates: - OpportunityController: CW refresh, company hydration, activities, custom fields - UserController: cwIdentifier field for CW member linking - CatalogItemController: category/subcategory fields from CW - PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions - API routes: procurement categories/filters, sales notes/products, opportunity types - Global events: UDF and member refresh intervals on startup Tests (414 passing): - ActivityController, ForecastProductController, OpportunityController unit tests - UserController cwIdentifier tests - catalogCategories, companySites, memberCache, procurement module tests - activityTypes, opportunityTypes, quoteStatuses type tests - permissionNodes subCategories and getAllPermissionNodes tests - Updated test setup with redis mock, API method mocks, and builder helpers
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import { describe, test, expect } from "bun:test";
|
|
import { ForecastProductController } from "../../../src/controllers/ForecastProductController";
|
|
import { buildMockCWForecastItem } from "../../setup";
|
|
|
|
describe("ForecastProductController", () => {
|
|
// -------------------------------------------------------------------
|
|
// Constructor
|
|
// -------------------------------------------------------------------
|
|
describe("constructor", () => {
|
|
test("sets core identification fields", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.cwForecastId).toBe(7001);
|
|
expect(ctrl.forecastDescription).toBe("Network Switch");
|
|
});
|
|
|
|
test("maps opportunity reference", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.opportunityCwId).toBe(1001);
|
|
expect(ctrl.opportunityName).toBe("Test Opportunity");
|
|
});
|
|
|
|
test("maps quantity", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.quantity).toBe(5);
|
|
});
|
|
|
|
test("maps status reference", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.statusCwId).toBe(1);
|
|
expect(ctrl.statusName).toBe("Won");
|
|
});
|
|
|
|
test("maps catalogItem reference", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.catalogItemCwId).toBe(500);
|
|
expect(ctrl.catalogItemIdentifier).toBe("USW-Pro-24");
|
|
});
|
|
|
|
test("maps product details", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.productDescription).toBe("UniFi Switch Pro 24");
|
|
expect(ctrl.productClass).toBe("Product");
|
|
expect(ctrl.forecastType).toBe("Product");
|
|
});
|
|
|
|
test("maps financials", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.revenue).toBe(2500.0);
|
|
expect(ctrl.cost).toBe(1800.0);
|
|
expect(ctrl.margin).toBe(700.0);
|
|
expect(ctrl.percentage).toBe(100);
|
|
});
|
|
|
|
test("maps boolean flags", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.includeFlag).toBe(true);
|
|
expect(ctrl.linkFlag).toBe(false);
|
|
expect(ctrl.recurringFlag).toBe(false);
|
|
expect(ctrl.taxableFlag).toBe(true);
|
|
});
|
|
|
|
test("maps sequence and sub number", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.sequenceNumber).toBe(1);
|
|
expect(ctrl.subNumber).toBe(0);
|
|
});
|
|
|
|
test("maps recurring fields", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.recurringRevenue).toBe(0);
|
|
expect(ctrl.recurringCost).toBe(0);
|
|
expect(ctrl.cycles).toBe(0);
|
|
});
|
|
|
|
test("sets cancellation defaults", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.cancelledFlag).toBe(false);
|
|
expect(ctrl.quantityCancelled).toBe(0);
|
|
expect(ctrl.cancelledReason).toBeNull();
|
|
expect(ctrl.cancelledBy).toBeNull();
|
|
expect(ctrl.cancelledDate).toBeNull();
|
|
});
|
|
|
|
test("sets inventory defaults", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.onHand).toBeNull();
|
|
expect(ctrl.inStock).toBeNull();
|
|
});
|
|
|
|
test("maps _info to cwLastUpdated", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.cwLastUpdated).toBeInstanceOf(Date);
|
|
expect(ctrl.cwUpdatedBy).toBe("jroberts");
|
|
});
|
|
|
|
test("handles missing optional fields", () => {
|
|
const ctrl = new ForecastProductController(
|
|
buildMockCWForecastItem({
|
|
opportunity: undefined,
|
|
status: undefined,
|
|
catalogItem: undefined,
|
|
_info: undefined,
|
|
}),
|
|
);
|
|
expect(ctrl.opportunityCwId).toBeNull();
|
|
expect(ctrl.statusCwId).toBeNull();
|
|
expect(ctrl.catalogItemCwId).toBeNull();
|
|
expect(ctrl.cwLastUpdated).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// applyCancellationData
|
|
// -------------------------------------------------------------------
|
|
describe("applyCancellationData()", () => {
|
|
test("applies cancellation data", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
ctrl.applyCancellationData({
|
|
cancelledFlag: true,
|
|
quantityCancelled: 3,
|
|
cancelledReason: "Out of stock",
|
|
cancelledBy: 42,
|
|
cancelledDate: "2026-02-20T00:00:00Z",
|
|
});
|
|
expect(ctrl.cancelledFlag).toBe(true);
|
|
expect(ctrl.quantityCancelled).toBe(3);
|
|
expect(ctrl.cancelledReason).toBe("Out of stock");
|
|
expect(ctrl.cancelledBy).toBe(42);
|
|
expect(ctrl.cancelledDate).toBeInstanceOf(Date);
|
|
});
|
|
|
|
test("handles partial cancellation data", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
ctrl.applyCancellationData({});
|
|
expect(ctrl.cancelledFlag).toBe(false);
|
|
expect(ctrl.quantityCancelled).toBe(0);
|
|
expect(ctrl.cancelledReason).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// applyInventoryData
|
|
// -------------------------------------------------------------------
|
|
describe("applyInventoryData()", () => {
|
|
test("sets onHand and inStock true when quantity > 0", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
ctrl.applyInventoryData({ onHand: 10 });
|
|
expect(ctrl.onHand).toBe(10);
|
|
expect(ctrl.inStock).toBe(true);
|
|
});
|
|
|
|
test("sets inStock false when onHand is 0", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
ctrl.applyInventoryData({ onHand: 0 });
|
|
expect(ctrl.onHand).toBe(0);
|
|
expect(ctrl.inStock).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Computed properties
|
|
// -------------------------------------------------------------------
|
|
describe("computed properties", () => {
|
|
test("profit returns revenue - cost", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.profit).toBe(700.0);
|
|
});
|
|
|
|
test("cancelled returns false by default", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.cancelled).toBe(false);
|
|
});
|
|
|
|
test("cancelled returns true after applyCancellationData", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 1 });
|
|
expect(ctrl.cancelled).toBe(true);
|
|
});
|
|
|
|
test("cancellationType returns null when not cancelled", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
expect(ctrl.cancellationType).toBeNull();
|
|
});
|
|
|
|
test("cancellationType returns 'full' when all units cancelled", () => {
|
|
const ctrl = new ForecastProductController(
|
|
buildMockCWForecastItem({ quantity: 5 }),
|
|
);
|
|
ctrl.applyCancellationData({
|
|
cancelledFlag: true,
|
|
quantityCancelled: 5,
|
|
});
|
|
expect(ctrl.cancellationType).toBe("full");
|
|
});
|
|
|
|
test("cancellationType returns 'partial' when some units cancelled", () => {
|
|
const ctrl = new ForecastProductController(
|
|
buildMockCWForecastItem({ quantity: 5 }),
|
|
);
|
|
ctrl.applyCancellationData({
|
|
cancelledFlag: true,
|
|
quantityCancelled: 2,
|
|
});
|
|
expect(ctrl.cancellationType).toBe("partial");
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// toJson
|
|
// -------------------------------------------------------------------
|
|
describe("toJson()", () => {
|
|
test("returns id as cwForecastId", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.id).toBe(7001);
|
|
});
|
|
|
|
test("returns financial fields", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.revenue).toBe(2500.0);
|
|
expect(json.cost).toBe(1800.0);
|
|
expect(json.margin).toBe(700.0);
|
|
expect(json.profit).toBe(700.0);
|
|
});
|
|
|
|
test("returns cancellation info", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.cancelled).toBe(false);
|
|
expect(json.cancellationType).toBeNull();
|
|
});
|
|
|
|
test("returns status as reference object", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.status).toEqual({ id: 1, name: "Won" });
|
|
});
|
|
|
|
test("returns catalogItem as reference object", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.catalogItem).toEqual({
|
|
id: 500,
|
|
identifier: "USW-Pro-24",
|
|
});
|
|
});
|
|
|
|
test("returns opportunity as reference object", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.opportunity).toEqual({
|
|
id: 1001,
|
|
name: "Test Opportunity",
|
|
});
|
|
});
|
|
|
|
test("includes inventory data", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
ctrl.applyInventoryData({ onHand: 10 });
|
|
const json = ctrl.toJson();
|
|
expect(json.onHand).toBe(10);
|
|
expect(json.inStock).toBe(true);
|
|
});
|
|
|
|
test("includes boolean flags", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.includeFlag).toBe(true);
|
|
expect(json.linkFlag).toBe(false);
|
|
expect(json.recurringFlag).toBe(false);
|
|
expect(json.taxableFlag).toBe(true);
|
|
});
|
|
|
|
test("includes sequence and timing info", () => {
|
|
const ctrl = new ForecastProductController(buildMockCWForecastItem());
|
|
const json = ctrl.toJson();
|
|
expect(json.sequenceNumber).toBe(1);
|
|
expect(json.subNumber).toBe(0);
|
|
expect(json.cwLastUpdated).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
});
|