Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee3e0a7377 | |||
| e294791858 | |||
| 97ac4a2173 | |||
| ad7507d133 | |||
| 15ef24eb3e | |||
| f53b390e18 |
@@ -14,6 +14,8 @@ jobs:
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
+397
@@ -901,6 +901,52 @@ Fetch configurations for a specific company from ConnectWise.
|
||||
|
||||
---
|
||||
|
||||
### Get Company Sites
|
||||
|
||||
**GET** `/company/companies/:identifier/sites`
|
||||
|
||||
Fetch all ConnectWise sites for a specific company.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `company.fetch`, `company.fetch.sites`
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `identifier` - Company ID
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Company Sites Fetched Successfully!",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Main Office",
|
||||
"address": {
|
||||
"line1": "123 Main St",
|
||||
"line2": null,
|
||||
"city": "Springfield",
|
||||
"state": "Illinois",
|
||||
"zip": "62704",
|
||||
"country": "United States"
|
||||
},
|
||||
"phoneNumber": "555-123-4567",
|
||||
"faxNumber": null,
|
||||
"primaryAddressFlag": true,
|
||||
"defaultShippingFlag": true,
|
||||
"defaultBillingFlag": true,
|
||||
"defaultMailingFlag": true
|
||||
}
|
||||
],
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Company UniFi Sites
|
||||
|
||||
**GET** `/company/companies/:identifier/unifi/sites`
|
||||
@@ -3619,6 +3665,41 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company,
|
||||
|
||||
---
|
||||
|
||||
### Delete Opportunity
|
||||
|
||||
**DELETE** `/sales/opportunities/:identifier`
|
||||
|
||||
Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.delete`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Opportunity deleted successfully!",
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
| Status | Description |
|
||||
| ------- | -------------------------------------------------- |
|
||||
| 200 | Opportunity deleted successfully |
|
||||
| 401 | Missing or invalid auth token |
|
||||
| 403 | User lacks `sales.opportunity.delete` permission |
|
||||
| 404 | Opportunity not found |
|
||||
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
|
||||
| 500 | Unexpected server error |
|
||||
|
||||
---
|
||||
|
||||
### Get Opportunity Products
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/products`
|
||||
@@ -3889,6 +3970,43 @@ Set cancellation state for a product line item using procurement cancellation fi
|
||||
|
||||
---
|
||||
|
||||
### Delete Product from Opportunity
|
||||
|
||||
**DELETE** `/sales/opportunities/:identifier/products/:productId`
|
||||
|
||||
Remove a forecast item (product) from an opportunity in ConnectWise. The item is also removed from the local `productSequence` array and the products cache is invalidated.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.product.delete`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
- `productId` — ConnectWise forecast item ID (positive integer)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Product deleted from opportunity successfully!",
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
| Status | Description |
|
||||
| ------- | -------------------------------------------------------- |
|
||||
| 200 | Product deleted successfully |
|
||||
| 400 | Invalid productId |
|
||||
| 401 | Missing or invalid auth token |
|
||||
| 403 | User lacks `sales.opportunity.product.delete` permission |
|
||||
| 404 | Opportunity or forecast item not found |
|
||||
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
|
||||
| 500 | Unexpected server error |
|
||||
|
||||
---
|
||||
|
||||
### Add Product to Opportunity
|
||||
|
||||
**POST** `/sales/opportunities/:identifier/products`
|
||||
@@ -4769,6 +4887,285 @@ Fetch contacts associated with an opportunity. Data is served from the Redis cac
|
||||
|
||||
---
|
||||
|
||||
## Opportunity Workflow (Internal Engine)
|
||||
|
||||
The opportunity workflow system is an internal engine that manages the lifecycle of opportunities through a defined set of statuses and transitions. Workflow actions are invoked programmatically via `processOpportunityAction()` from [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), and are exposed to the UI via the HTTP API routes documented below.
|
||||
|
||||
**Stage gate:** All workflow actions require the opportunity's `stageName` to be `"Optima"`. Actions on opportunities in any other stage are rejected.
|
||||
|
||||
### Statuses
|
||||
|
||||
| Enum Key | CW Status ID | CW Name | Terminal | Notes |
|
||||
| --------------- | ------------ | ------------------- | -------- | ----------------------------------------------------------------- |
|
||||
| PendingNew | 37 | 00. Pending New | No | Default status before acceptance |
|
||||
| New | 24 | 01. New | No | Setup phase — assembling line items, discounts, etc. |
|
||||
| InternalReview | 56 | 02. Internal Review | No | Flagged for internal review (manual or cold-detection automation) |
|
||||
| QuoteSent | 43 | 03. Quote Sent | No | Quote has been sent to the customer |
|
||||
| ConfirmedQuote | 57 | 04. Confirmed Quote | No | Customer acknowledged receipt |
|
||||
| Active | 58 | 05. Active | No | Quote in revision/flux |
|
||||
| PendingSent | 60 | Pending Sent | No | Review approved, awaiting rep to send |
|
||||
| PendingRevision | 61 | Pending Revision | No | Review rejected, awaiting rep revision |
|
||||
| PendingWon | 49 | 91. Pending Won | No | Won pending finalization by authorized user |
|
||||
| Won | 29 | 95. Won | Yes | Final positive outcome — immutable |
|
||||
| PendingLost | 50 | 98. Pending Lost | No | Lost pending finalization or resurrection |
|
||||
| Lost | 53 | 99. Lost | Yes | Final negative outcome — immutable |
|
||||
| Canceled | 59 | Canceled | No\* | Not pursued; can be re-opened to Active |
|
||||
|
||||
### Transition Map
|
||||
|
||||
| From | Allowed Targets |
|
||||
| --------------- | ------------------------------------------------------------------------------- |
|
||||
| PendingNew | New |
|
||||
| New | InternalReview, QuoteSent, Canceled |
|
||||
| InternalReview | PendingSent (approve), PendingRevision (reject), QuoteSent (send), Canceled |
|
||||
| PendingSent | QuoteSent |
|
||||
| PendingRevision | Active |
|
||||
| QuoteSent | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
|
||||
| ConfirmedQuote | Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
|
||||
| Active | QuoteSent, InternalReview, Canceled |
|
||||
| PendingWon | Won |
|
||||
| PendingLost | Lost, Active (resurrection) |
|
||||
| Won | _(terminal — no transitions)_ |
|
||||
| Lost | _(terminal — no transitions)_ |
|
||||
| Canceled | Active (re-open) |
|
||||
|
||||
### Workflow Actions
|
||||
|
||||
| Action | Description | Required Permission | Note Required |
|
||||
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------- | ------------- |
|
||||
| acceptNew | PendingNew → New | — | No |
|
||||
| requestReview | → InternalReview (manual) | — | Yes |
|
||||
| reviewDecision | InternalReview → approve/reject/send/cancel | `sales.opportunity.cancel` (cancel only) | Yes |
|
||||
| sendQuote | → QuoteSent (with compound flags: won, lost, needsRevision, quoteConfirmed) | `sales.opportunity.finalize` (won flag only) | No |
|
||||
| confirmQuote | QuoteSent → ConfirmedQuote | — | No |
|
||||
| finalize | → Won/Lost (or PendingWon/PendingLost without finalize permission) | `sales.opportunity.finalize` | Yes |
|
||||
| resurrect | PendingLost → Active | — | Yes |
|
||||
| beginRevision | PendingRevision → Active | — | No |
|
||||
| resendQuote | Active → QuoteSent (re-send after revision) | — | No |
|
||||
| cancel | → Canceled | `sales.opportunity.cancel` | Yes |
|
||||
| reopen | Canceled → Active | — | Yes |
|
||||
|
||||
### CW Activity Custom Field — Optima_Type
|
||||
|
||||
Every workflow activity is tagged with an `Optima_Type` custom field (CW field ID 45) to identify its type without parsing notes:
|
||||
|
||||
| Value | Used For |
|
||||
| ---------------------- | ------------------------------------------------------------- |
|
||||
| Opportunity Created | Initial opportunity creation |
|
||||
| Opportunity Setup | New/setup phase activities (stays open until next transition) |
|
||||
| Opportunity Review | Review submissions (stays open until next transition) |
|
||||
| Quote Sent | Quote-sent activities |
|
||||
| Quote Confirmed | Standalone quote confirmation activities |
|
||||
| Quote Sent & Confirmed | Combined send + confirm in a single action |
|
||||
| Quote Generated | Quote commit/generation activities |
|
||||
| Revision | Revision activities (stays open until next transition) |
|
||||
| Finalized | PendingWon, PendingLost, Lost, and cancel activities |
|
||||
| Converted | Won (finalized) activities |
|
||||
|
||||
### Cold Detection
|
||||
|
||||
The cold-detection algorithm ([src/modules/algorithms/algo.coldThreshold.ts](src/modules/algorithms/algo.coldThreshold.ts)) evaluates stall thresholds:
|
||||
|
||||
- **QuoteSent**: 14 days with no activity → auto-transition to InternalReview
|
||||
- **ConfirmedQuote**: 30 days with no activity → auto-transition to InternalReview
|
||||
|
||||
Triggered via `triggerColdDetection()` (intended for schedulers/automation, not user actions).
|
||||
|
||||
### Supporting Modules
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| [src/modules/algorithms/algo.coldThreshold.ts](src/modules/algorithms/algo.coldThreshold.ts) | Cold-detection config and `checkColdStatus()` |
|
||||
| [src/modules/algorithms/algo.followUpScheduler.ts](src/modules/algorithms/algo.followUpScheduler.ts) | Follow-up scheduling (placeholder: next business day at 10am) |
|
||||
| [src/services/cw.opportunityService.ts](src/services/cw.opportunityService.ts) | CW integration stubs: `submitTimeEntry()`, `createScheduleEntry()`, `syncOpportunityStatus()` |
|
||||
|
||||
### Workflow API Routes
|
||||
|
||||
These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/:identifier/workflow`.
|
||||
|
||||
---
|
||||
|
||||
#### `GET /v1/sales/opportunities/:identifier/workflow`
|
||||
|
||||
Fetch the current workflow state for an opportunity — current status, stage, available actions, cold-detection result, and whether each action is permitted for the authenticated user.
|
||||
|
||||
**Auth:** Bearer token required
|
||||
**Permission:** `sales.opportunity.fetch`
|
||||
**Source:** [src/api/sales/opportunities/[id]/workflow/status.ts](src/api/sales/opportunities/[id]/workflow/status.ts)
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Workflow status fetched successfully.",
|
||||
"status": 200,
|
||||
"data": {
|
||||
"currentStatusId": 43,
|
||||
"currentStatus": "QuoteSent",
|
||||
"stageName": "Optima",
|
||||
"isOptimaStage": true,
|
||||
"isTerminal": false,
|
||||
"availableActions": [
|
||||
{
|
||||
"action": "confirmQuote",
|
||||
"label": "Confirm Quote Receipt",
|
||||
"targetStatuses": [{ "key": "ConfirmedQuote", "id": 57 }],
|
||||
"requiresNote": false,
|
||||
"requiresPermission": null,
|
||||
"permitted": true
|
||||
},
|
||||
{
|
||||
"action": "finalize",
|
||||
"label": "Mark as Won",
|
||||
"targetStatuses": [
|
||||
{ "key": "Won", "id": 29 },
|
||||
{ "key": "PendingWon", "id": 49 }
|
||||
],
|
||||
"requiresNote": true,
|
||||
"requiresPermission": null,
|
||||
"payloadHints": { "outcome": "\"won\"" },
|
||||
"permitted": true
|
||||
}
|
||||
],
|
||||
"coldCheck": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> When `isOptimaStage` is `false`, `availableActions` is always an empty array — no workflow actions are permitted outside the Optima stage.
|
||||
|
||||
---
|
||||
|
||||
#### `POST /v1/sales/opportunities/:identifier/workflow`
|
||||
|
||||
Execute a workflow action. Accepts a discriminated union of `{ action, payload }` and routes it through the workflow engine. The body is validated with Zod.
|
||||
|
||||
**Auth:** Bearer token required
|
||||
**Permission:** `sales.opportunity.workflow` (base gate). Additionally:
|
||||
|
||||
- `sales.opportunity.finalize` — for `finalize` action producing Won/Lost
|
||||
- `sales.opportunity.cancel` — for `cancel` action and `reviewDecision` with `decision: "cancel"`
|
||||
|
||||
**Source:** [src/api/sales/opportunities/[id]/workflow/dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts)
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sendQuote",
|
||||
"payload": {
|
||||
"note": "Sending quote to customer after review.",
|
||||
"timeSpent": 30,
|
||||
"quoteConfirmed": false,
|
||||
"won": false,
|
||||
"lost": false,
|
||||
"needsRevision": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>All action types and their payloads</strong></summary>
|
||||
|
||||
| Action | Required Payload Fields | Optional Payload Fields | Description |
|
||||
| ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `acceptNew` | — | `note`, `timeSpent` | PendingNew → New |
|
||||
| `requestReview` | `note` | `timeSpent` | New/Active → InternalReview |
|
||||
| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → PendingSent/PendingRevision/QuoteSent/Canceled |
|
||||
| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | PendingSent/New → QuoteSent (+ compound transitions) |
|
||||
| `confirmQuote` | — | `note`, `timeSpent` | QuoteSent → ConfirmedQuote |
|
||||
| `finalize` | `note`, `outcome` (`"won"` or `"lost"`) | `timeSpent` | Pending → Won/Lost (or direct if permitted) |
|
||||
| `resurrect` | `note` | `timeSpent` | PendingLost → Active |
|
||||
| `beginRevision` | — | `note`, `timeSpent` | PendingRevision → Active |
|
||||
| `resendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | Active → QuoteSent (+ compound transitions) |
|
||||
| `cancel` | `note` | `timeSpent` | New/Active → Canceled (requires cancel perm) |
|
||||
| `reopen` | `note` | `timeSpent` | Canceled → Active |
|
||||
|
||||
`decision` values for `reviewDecision`: `"approve"`, `"reject"`, `"send"`, `"cancel"`
|
||||
|
||||
</details>
|
||||
|
||||
**Response (200) — success:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Workflow action completed successfully.",
|
||||
"status": 200,
|
||||
"data": {
|
||||
"previousStatusId": 60,
|
||||
"previousStatus": "PendingSent",
|
||||
"newStatusId": 43,
|
||||
"newStatus": "QuoteSent",
|
||||
"activitiesCreated": [ { "...activity JSON..." } ],
|
||||
"coldCheck": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (422) — transition rejected:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Workflow action failed.",
|
||||
"status": 422,
|
||||
"name": "WorkflowTransitionFailed",
|
||||
"data": {
|
||||
"previousStatusId": 29,
|
||||
"previousStatus": "Won",
|
||||
"newStatusId": null,
|
||||
"newStatus": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `GET /v1/sales/opportunities/:identifier/workflow/history`
|
||||
|
||||
Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid `Optima_Type` custom field set, sorted newest first.
|
||||
|
||||
**Auth:** Bearer token required
|
||||
**Permission:** `sales.opportunity.fetch`
|
||||
**Source:** [src/api/sales/opportunities/[id]/workflow/history.ts](src/api/sales/opportunities/[id]/workflow/history.ts)
|
||||
|
||||
**Query parameters:**
|
||||
| Param | Type | Description |
|
||||
| ------ | ------ | ------------------------------------------------- |
|
||||
| `type` | string | Filter by Optima_Type value (e.g. `"Quote Sent"`) |
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Workflow history fetched successfully.",
|
||||
"status": 200,
|
||||
"data": {
|
||||
"opportunityId": "clx...",
|
||||
"cwOpportunityId": 12345,
|
||||
"totalActivities": 3,
|
||||
"activities": [
|
||||
{
|
||||
"activity": { "...activity JSON..." },
|
||||
"optimaType": "Quote Sent",
|
||||
"quoteId": "QUO-12345",
|
||||
"closed": true,
|
||||
"closedAt": "2026-03-09T05:40:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | -------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `activity` | object | Full CW activity JSON from `ActivityController.toJson()` |
|
||||
| `optimaType` | string | The `Optima_Type` custom field value (e.g. `"Quote Sent"`, `"Opportunity Setup"`) |
|
||||
| `quoteId` | string \| null | The `QuoteID` custom field value (CW field id 48), or `null` if not set |
|
||||
| `closed` | boolean | `true` when the CW activity status is Closed (status id 2) |
|
||||
| `closedAt` | string \| null | ISO-8601 timestamp from the `Close Date` custom field (CW field id 49), or `null` if not set |
|
||||
|
||||
---
|
||||
|
||||
## UniFi Routes
|
||||
|
||||
All UniFi routes require the `unifi.access` permission in addition to their route-specific permission. This acts as a gate for the entire UniFi API.
|
||||
|
||||
+14
-2
@@ -24,12 +24,13 @@ The permission validator supports special tokens for flexible permission managem
|
||||
### Company Permissions
|
||||
|
||||
| Permission Node | Description | Used In |
|
||||
| ------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| ------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `company.fetch` | Fetch a single company | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.address` | View company address information (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.contacts` | View all company contacts (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.many` | Fetch multiple companies | [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts) |
|
||||
| `company.fetch.configurations` | Fetch company configurations (requires `company.fetch` as well) | [src/api/companies/[id]/configurations.ts](src/api/companies/[id]/configurations.ts) |
|
||||
| `company.fetch.sites` | Fetch company sites from ConnectWise (requires `company.fetch` as well) | [src/api/companies/[id]/sites.ts](src/api/companies/[id]/sites.ts) |
|
||||
|
||||
### Credential Permissions
|
||||
|
||||
@@ -142,16 +143,18 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
||||
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
|
||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
|
||||
| `sales.opportunity.delete` | Delete an opportunity from ConnectWise and the local database | [src/api/sales/opportunities/[id]/delete.ts](src/api/sales/opportunities/[id]/delete.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/opportunities/[id]/notes/create.ts](src/api/sales/opportunities/[id]/notes/create.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/opportunities/[id]/notes/update.ts](src/api/sales/opportunities/[id]/notes/update.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/opportunities/[id]/notes/delete.ts](src/api/sales/opportunities/[id]/notes/delete.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/opportunities/[id]/products/resequence.ts](src/api/sales/opportunities/[id]/products/resequence.ts), [src/api/sales/opportunities/[id]/products/update.ts](src/api/sales/opportunities/[id]/products/update.ts), [src/api/sales/opportunities/[id]/products/cancel.ts](src/api/sales/opportunities/[id]/products/cancel.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.delete` | Delete a product (forecast item) from an opportunity | [src/api/sales/opportunities/[id]/products/delete.ts](src/api/sales/opportunities/[id]/products/delete.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/opportunities/[id]/products/add.ts](src/api/sales/opportunities/[id]/products/add.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/opportunities/[id]/products/addSpecialOrder.ts](src/api/sales/opportunities/[id]/products/addSpecialOrder.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/opportunities/[id]/products/addLabor.ts](src/api/sales/opportunities/[id]/products/addLabor.ts), [src/api/sales/opportunities/[id]/products/laborOptions.ts](src/api/sales/opportunities/[id]/products/laborOptions.ts) | `sales.opportunity.fetch` |
|
||||
@@ -162,6 +165,15 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
||||
| `sales.opportunity.quote.fetch_downloads` | Fetch download/print history for all quotes on an opportunity. Admin-level permission. | [src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts](src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.view_margin` | View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.view_cost` | View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.view_profit` | View profit data on opportunity products. Controls visibility of profit values in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.workflow` | Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint. | [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.finalize` | Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` |
|
||||
| `sales.opportunity.cancel` | Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` |
|
||||
| `sales.opportunity.review` | Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||
| `sales.opportunity.send` | Send a quote to the customer. Required to transition an opportunity to QuoteSent (and compound transitions like immediate won/lost/confirmed). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||
| `sales.opportunity.reopen` | Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||
| `sales.opportunity.win` | Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||
| `sales.opportunity.lose` | Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||
|
||||
<details>
|
||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { companies } from "../../../managers/companies";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* /v1/company/companies/[id]/sites */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/companies/:identifier/sites"],
|
||||
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const sites = await company.fetchSites();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Sites Fetched Successfully!",
|
||||
sites,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["company.fetch", "company.fetch.sites"],
|
||||
}),
|
||||
);
|
||||
@@ -1,7 +1,8 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as configurations } from "./[id]/configurations";
|
||||
import { default as sites } from "./[id]/sites";
|
||||
import { default as unifiSites } from "./[id]/unifiSites";
|
||||
import { default as count } from "./count";
|
||||
|
||||
export { configurations, count, fetch, fetchAll, unifiSites };
|
||||
export { configurations, count, fetch, fetchAll, sites, unifiSites };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { default as count } from "./opportunities/count";
|
||||
import { default as fetch } from "./opportunities/[id]/fetch";
|
||||
import { default as refresh } from "./opportunities/[id]/refresh";
|
||||
import { default as updateOpportunity } from "./opportunities/[id]/update";
|
||||
import { default as deleteOpportunity } from "./opportunities/[id]/delete";
|
||||
import { default as products } from "./opportunities/[id]/products/fetchAll";
|
||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||
@@ -13,6 +14,7 @@ import { default as laborOptions } from "./opportunities/[id]/products/laborOpti
|
||||
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
|
||||
import { default as updateProduct } from "./opportunities/[id]/products/update";
|
||||
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
|
||||
import { default as deleteProduct } from "./opportunities/[id]/products/delete";
|
||||
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
|
||||
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
||||
import { default as createNote } from "./opportunities/[id]/notes/create";
|
||||
@@ -24,6 +26,9 @@ import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
|
||||
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
||||
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
||||
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
||||
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
|
||||
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
|
||||
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
|
||||
|
||||
export {
|
||||
addProduct,
|
||||
@@ -32,6 +37,7 @@ export {
|
||||
addSpecialOrderProduct,
|
||||
count,
|
||||
createOpportunity,
|
||||
deleteOpportunity,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchOpportunityTypes,
|
||||
@@ -39,6 +45,7 @@ export {
|
||||
resequenceProducts,
|
||||
updateProduct,
|
||||
cancelProduct,
|
||||
deleteProduct,
|
||||
notes,
|
||||
fetchNote,
|
||||
createNote,
|
||||
@@ -52,4 +59,7 @@ export {
|
||||
fetchDownloads,
|
||||
refresh,
|
||||
updateOpportunity,
|
||||
workflowDispatch,
|
||||
workflowStatus,
|
||||
workflowHistory,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import GenericError from "../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
try {
|
||||
await opportunities.deleteItem(identifier);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity deleted successfully!",
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwMessage: string =
|
||||
axiosErr.response?.data?.message ??
|
||||
"Failed to delete the opportunity in ConnectWise";
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseDeleteError",
|
||||
successful: false,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.delete"] }),
|
||||
);
|
||||
@@ -11,6 +11,7 @@ const productItemSchema = z
|
||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||
forecastDescription: z.string().optional(),
|
||||
productDescription: z.string().optional(),
|
||||
customerDescription: z.string().nullable().optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||
productClass: z.string().optional(),
|
||||
@@ -54,7 +55,40 @@ export default createRoute(
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const created = await item.addProducts(gatedItems);
|
||||
|
||||
// Strip customerDescription from forecast payloads — CW only accepts
|
||||
// it on procurement products, not forecast items.
|
||||
const customerDescriptions = gatedItems.map(
|
||||
(g: any) => g.customerDescription,
|
||||
);
|
||||
const forecastPayloads = gatedItems.map(
|
||||
({ customerDescription, ...rest }: any) => rest,
|
||||
);
|
||||
|
||||
const created = await item.addProducts(forecastPayloads);
|
||||
|
||||
// If any items included customerDescription, patch the linked
|
||||
// procurement products after creation. This is best-effort since
|
||||
// newly created forecast items may not have a linked procurement
|
||||
// product yet.
|
||||
const procurementUpdates = created
|
||||
.map((product, idx) => ({
|
||||
product,
|
||||
customerDescription: customerDescriptions[idx],
|
||||
}))
|
||||
.filter((entry) => entry.customerDescription != null);
|
||||
|
||||
if (procurementUpdates.length > 0) {
|
||||
await Promise.all(
|
||||
procurementUpdates.map(({ product, customerDescription }) =>
|
||||
item
|
||||
.updateProcurementProductByForecastItem(product.cwForecastId, {
|
||||
customerDescription,
|
||||
})
|
||||
.catch(() => null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier/products/:productId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier/products/:productId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
// Verify the forecast item exists before attempting deletion
|
||||
const products = await opportunity.fetchProducts();
|
||||
const product = products.find((item) => item.cwForecastId === productId);
|
||||
|
||||
if (!product) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await opportunity.deleteProduct(productId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product deleted from opportunity successfully!",
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwMessage: string =
|
||||
axiosErr.response?.data?.message ??
|
||||
"Failed to delete the product in ConnectWise";
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseDeleteError",
|
||||
successful: false,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.delete"] }),
|
||||
);
|
||||
@@ -98,12 +98,6 @@ export default createRoute(
|
||||
if (input.quantity !== undefined) {
|
||||
forecastPatch.quantity = input.quantity;
|
||||
}
|
||||
if (
|
||||
input.customerDescription !== undefined &&
|
||||
input.customerDescription !== null
|
||||
) {
|
||||
forecastPatch.customerDescription = input.customerDescription;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
forecastPatch.revenue = Number(
|
||||
(input.unitPrice * effectiveQuantity).toFixed(2),
|
||||
|
||||
@@ -4,6 +4,11 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
import { cwMembers } from "../../../../../managers/cwMembers";
|
||||
import {
|
||||
createWorkflowActivity,
|
||||
OptimaType,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
const commitQuoteSchema = z
|
||||
.object({
|
||||
@@ -29,6 +34,34 @@ export default createRoute(
|
||||
|
||||
const quote = await item.commitQuote(opts ?? {}, user);
|
||||
|
||||
// Create a workflow activity for the generated quote
|
||||
try {
|
||||
let cwMemberId: number | null = null;
|
||||
|
||||
if (user.cwIdentifier) {
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
cwMemberId = cwMember.cwMemberId;
|
||||
}
|
||||
|
||||
if (cwMemberId) {
|
||||
await createWorkflowActivity({
|
||||
name: `[Workflow] Quote generated — ${item.name}`,
|
||||
opportunityCwId: item.cwOpportunityId,
|
||||
companyCwId: item.companyCwId,
|
||||
assignToCwMemberId: cwMemberId,
|
||||
notes: `Quote "${quote.quoteFileName}" generated.`,
|
||||
optimaType: OptimaType.QuoteGenerated,
|
||||
quoteId: quote.id,
|
||||
});
|
||||
}
|
||||
} catch (activityErr) {
|
||||
console.error(
|
||||
"[Quote Commit] Failed to create workflow activity:",
|
||||
activityErr,
|
||||
);
|
||||
// Don't fail the quote commit if the activity fails
|
||||
}
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Quote committed successfully!",
|
||||
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { z } from "zod";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { cwMembers } from "../../../../../managers/cwMembers";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import {
|
||||
processOpportunityAction,
|
||||
type WorkflowAction,
|
||||
type WorkflowUser,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
// ── Zod schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
const basePayload = z.object({
|
||||
note: z.string().optional(),
|
||||
timeStarted: z.string().datetime().optional(),
|
||||
timeEnded: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const noteRequiredPayload = z.object({
|
||||
note: z.string().min(1, "A non-empty note is required."),
|
||||
timeStarted: z.string().datetime().optional(),
|
||||
timeEnded: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const dispatchSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("acceptNew"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("requestReview"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("reviewDecision"),
|
||||
payload: noteRequiredPayload.extend({
|
||||
decision: z.enum(["approve", "reject", "send", "cancel"]),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("sendQuote"),
|
||||
payload: basePayload.extend({
|
||||
quoteConfirmed: z.boolean().optional(),
|
||||
won: z.boolean().optional(),
|
||||
lost: z.boolean().optional(),
|
||||
finalize: z.boolean().optional(),
|
||||
needsRevision: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("confirmQuote"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("finalize"),
|
||||
payload: noteRequiredPayload.extend({
|
||||
outcome: z.enum(["won", "lost"]),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("resurrect"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("beginRevision"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("resendQuote"),
|
||||
payload: basePayload.extend({
|
||||
quoteConfirmed: z.boolean().optional(),
|
||||
won: z.boolean().optional(),
|
||||
lost: z.boolean().optional(),
|
||||
finalize: z.boolean().optional(),
|
||||
needsRevision: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("cancel"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("reopen"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ── Route ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/workflow */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/workflow"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
console.log(
|
||||
"[Workflow Dispatch] Raw request body:",
|
||||
JSON.stringify(body, null, 2),
|
||||
);
|
||||
const parsed = dispatchSchema.parse(body);
|
||||
console.log(
|
||||
"[Workflow Dispatch] Parsed payload:",
|
||||
JSON.stringify(parsed.payload, null, 2),
|
||||
);
|
||||
const user = c.get("user");
|
||||
|
||||
// ── Resolve opportunity ────────────────────────────────────────────
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
// ── Build WorkflowUser ─────────────────────────────────────────────
|
||||
if (!user.cwIdentifier) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "MissingCwIdentifier",
|
||||
message:
|
||||
"Your account is not linked to a ConnectWise member. A CW member association is required to execute workflow actions.",
|
||||
});
|
||||
}
|
||||
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
const permissions = await user.readAllPermissions();
|
||||
|
||||
const workflowUser: WorkflowUser = {
|
||||
id: user.id,
|
||||
cwMemberId: cwMember.cwMemberId,
|
||||
permissions,
|
||||
};
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────────────
|
||||
const result = await processOpportunityAction(
|
||||
opportunity,
|
||||
parsed as WorkflowAction,
|
||||
workflowUser,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[Workflow Dispatch] Transition failed for opportunity "${identifier}":`,
|
||||
result.error,
|
||||
);
|
||||
const response = apiResponse.error(
|
||||
new GenericError({
|
||||
status: 422,
|
||||
name: "WorkflowTransitionFailed",
|
||||
message: result.error ?? "Workflow action failed.",
|
||||
}),
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
...response,
|
||||
data: {
|
||||
previousStatusId: result.previousStatusId,
|
||||
previousStatus: result.previousStatus,
|
||||
newStatusId: result.newStatusId,
|
||||
newStatus: result.newStatus,
|
||||
},
|
||||
},
|
||||
response.status as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow action completed successfully.",
|
||||
{
|
||||
previousStatusId: result.previousStatusId,
|
||||
previousStatus: result.previousStatus,
|
||||
newStatusId: result.newStatusId,
|
||||
newStatus: result.newStatus,
|
||||
activitiesCreated: result.activitiesCreated.map((a) => a.toJson()),
|
||||
coldCheck: result.coldCheck,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err: any) {
|
||||
console.error("[Workflow Dispatch] Unhandled error:", err);
|
||||
if (err?.response?.data) {
|
||||
console.error(
|
||||
"[Workflow Dispatch] CW response body:",
|
||||
JSON.stringify(err.response.data, null, 2),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.workflow"] }),
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
|
||||
import { ActivityController } from "../../../../../controllers/ActivityController";
|
||||
import { OptimaType } from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const OPTIMA_TYPE_VALUES = new Set<string>([
|
||||
OptimaType.OpportunityCreated,
|
||||
OptimaType.OpportunitySetup,
|
||||
OptimaType.OpportunityReview,
|
||||
OptimaType.QuoteSent,
|
||||
OptimaType.QuoteConfirmed,
|
||||
OptimaType.QuoteSentConfirmed,
|
||||
OptimaType.QuoteGenerated,
|
||||
OptimaType.Revision,
|
||||
OptimaType.Finalized,
|
||||
OptimaType.Converted,
|
||||
]);
|
||||
|
||||
/** QuoteID custom field ID (matches wf.opportunity.ts QUOTE_ID_FIELD_ID). */
|
||||
const QUOTE_ID_FIELD_ID = 48;
|
||||
|
||||
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
|
||||
const CLOSE_DATE_FIELD_ID = 49;
|
||||
|
||||
/**
|
||||
* Extract the Optima_Type value from a CW activity's custom fields.
|
||||
* Returns the string value if present, or null.
|
||||
*/
|
||||
function extractOptimaType(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === OptimaType.FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return OPTIMA_TYPE_VALUES.has(field.value) ? field.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the QuoteID custom field value from a CW activity.
|
||||
* Returns the string value or null.
|
||||
*/
|
||||
function extractQuoteId(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === QUOTE_ID_FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return field.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Close Date custom field value from a CW activity.
|
||||
* Returns the ISO-8601 string or null.
|
||||
*/
|
||||
function extractCloseDate(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === CLOSE_DATE_FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return field.value;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/workflow/history */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/workflow/history"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const filterType = c.req.query("type") ?? null; // optional filter by Optima_Type value
|
||||
|
||||
// Resolve the opportunity to get the CW opportunity ID
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
// Fetch all activities for this opportunity from CW
|
||||
const activitiesCollection = await activityCw.fetchByOpportunity(
|
||||
opportunity.cwOpportunityId,
|
||||
);
|
||||
|
||||
// Filter to workflow activities (those with a valid Optima_Type)
|
||||
const workflowActivities: {
|
||||
activity: ReturnType<ActivityController["toJson"]>;
|
||||
optimaType: string;
|
||||
quoteId: string | null;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
}[] = [];
|
||||
|
||||
for (const [, raw] of activitiesCollection) {
|
||||
const controller = new ActivityController(raw);
|
||||
const json = controller.toJson();
|
||||
const optimaType = extractOptimaType(raw.customFields);
|
||||
|
||||
if (!optimaType) continue;
|
||||
if (filterType && optimaType !== filterType) continue;
|
||||
|
||||
const quoteId = extractQuoteId(raw.customFields);
|
||||
const closed = raw.status?.id === 2;
|
||||
const closedAt = extractCloseDate(raw.customFields);
|
||||
|
||||
workflowActivities.push({
|
||||
activity: json,
|
||||
optimaType,
|
||||
quoteId,
|
||||
closed,
|
||||
closedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
workflowActivities.sort((a, b) => {
|
||||
const dateA = new Date(
|
||||
a.activity.dateEnd ?? a.activity.dateStart ?? 0,
|
||||
).getTime();
|
||||
const dateB = new Date(
|
||||
b.activity.dateEnd ?? b.activity.dateStart ?? 0,
|
||||
).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow history fetched successfully.",
|
||||
{
|
||||
opportunityId: opportunity.id,
|
||||
cwOpportunityId: opportunity.cwOpportunityId,
|
||||
totalActivities: workflowActivities.length,
|
||||
activities: workflowActivities,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
console.error("[Workflow History] Unhandled error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,377 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import {
|
||||
OpportunityStatus,
|
||||
StatusIdToKey,
|
||||
WorkflowPermissions,
|
||||
type OpportunityStatusKey,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
import {
|
||||
checkColdStatus,
|
||||
type ColdCheckResult,
|
||||
} from "../../../../../modules/algorithms/algo.coldThreshold";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ACTION AVAILABILITY MAP
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Per-status list of actions the user can invoke.
|
||||
*
|
||||
* Each entry describes the action key, a human-readable label, the
|
||||
* expected target status(es), whether a note is required, and any
|
||||
* permission gate beyond the base workflow permission.
|
||||
*/
|
||||
interface AvailableAction {
|
||||
action: string;
|
||||
label: string;
|
||||
targetStatuses: { key: OpportunityStatusKey; id: number }[];
|
||||
requiresNote: boolean;
|
||||
requiresPermission: string | null;
|
||||
/** Extra payload fields that can/must be provided. */
|
||||
payloadHints?: Record<string, string>;
|
||||
}
|
||||
|
||||
const ACTION_MAP: Record<number, AvailableAction[]> = {
|
||||
[OpportunityStatus.PendingNew]: [
|
||||
{
|
||||
action: "acceptNew",
|
||||
label: "Accept / Set Up Opportunity",
|
||||
targetStatuses: [{ key: "New", id: OpportunityStatus.New }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.New]: [
|
||||
{
|
||||
action: "sendQuote",
|
||||
label: "Send Quote (skip review)",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: {
|
||||
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||
won: "boolean — immediate win",
|
||||
lost: "boolean — immediate rejection",
|
||||
needsRevision: "boolean — needs revision",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "requestReview",
|
||||
label: "Send to Internal Review",
|
||||
targetStatuses: [
|
||||
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: "Cancel Opportunity",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.InternalReview]: [
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Approve (move to Pending Sent)",
|
||||
targetStatuses: [
|
||||
{ key: "PendingSent", id: OpportunityStatus.PendingSent },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"approve"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Reject (move to Pending Revision)",
|
||||
targetStatuses: [
|
||||
{ key: "PendingRevision", id: OpportunityStatus.PendingRevision },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"reject"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Send Quote (reviewer sends directly)",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"send"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Cancel from Review",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
payloadHints: { decision: '"cancel"' },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingSent]: [
|
||||
{
|
||||
action: "sendQuote",
|
||||
label: "Send Quote to Customer",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: {
|
||||
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||
won: "boolean — immediate win",
|
||||
lost: "boolean — immediate rejection",
|
||||
needsRevision: "boolean — needs revision",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingRevision]: [
|
||||
{
|
||||
action: "beginRevision",
|
||||
label: "Begin Revision",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.QuoteSent]: [
|
||||
{
|
||||
action: "confirmQuote",
|
||||
label: "Confirm Quote Receipt",
|
||||
targetStatuses: [
|
||||
{ key: "ConfirmedQuote", id: OpportunityStatus.ConfirmedQuote },
|
||||
],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Won",
|
||||
targetStatuses: [
|
||||
{ key: "Won", id: OpportunityStatus.Won },
|
||||
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Lost",
|
||||
targetStatuses: [
|
||||
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Revise & Re-send (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: { needsRevision: "true" },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.ConfirmedQuote]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Won",
|
||||
targetStatuses: [
|
||||
{ key: "Won", id: OpportunityStatus.Won },
|
||||
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Lost",
|
||||
targetStatuses: [
|
||||
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Revise & Re-send (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: { needsRevision: "true" },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.Active]: [
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Send Revised Quote",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: {
|
||||
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||
won: "boolean — immediate win",
|
||||
lost: "boolean — immediate rejection",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "requestReview",
|
||||
label: "Send to Internal Review",
|
||||
targetStatuses: [
|
||||
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: "Cancel Opportunity",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingWon]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Approve Won",
|
||||
targetStatuses: [{ key: "Won", id: OpportunityStatus.Won }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.FINALIZE,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingLost]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Approve Lost",
|
||||
targetStatuses: [{ key: "Lost", id: OpportunityStatus.Lost }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.FINALIZE,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resurrect",
|
||||
label: "Resurrect (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.Won]: [],
|
||||
[OpportunityStatus.Lost]: [],
|
||||
|
||||
[OpportunityStatus.Canceled]: [
|
||||
{
|
||||
action: "reopen",
|
||||
label: "Re-open Opportunity",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/workflow */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/workflow"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const user = c.get("user");
|
||||
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
const statusCwId = opportunity.statusCwId;
|
||||
const statusKey: OpportunityStatusKey | null =
|
||||
statusCwId != null ? (StatusIdToKey[statusCwId] ?? null) : null;
|
||||
|
||||
const isOptimaStage = opportunity.stageName === "Optima";
|
||||
const isTerminal =
|
||||
statusCwId === OpportunityStatus.Won ||
|
||||
statusCwId === OpportunityStatus.Lost;
|
||||
|
||||
// ── Resolve available actions (permission-aware) ──────────────────
|
||||
const rawActions =
|
||||
statusCwId != null ? (ACTION_MAP[statusCwId] ?? []) : [];
|
||||
|
||||
const resolvedActions = await Promise.all(
|
||||
rawActions.map(async (a) => {
|
||||
const hasGate =
|
||||
!a.requiresPermission ||
|
||||
(await user.hasPermission(a.requiresPermission));
|
||||
return { ...a, permitted: hasGate };
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Cold check (only for QuoteSent / ConfirmedQuote) ──────────────
|
||||
let coldCheck: ColdCheckResult | null = null;
|
||||
if (
|
||||
statusCwId === OpportunityStatus.QuoteSent ||
|
||||
statusCwId === OpportunityStatus.ConfirmedQuote
|
||||
) {
|
||||
// Fetch activities to determine latest activity date
|
||||
const activities = await opportunity.fetchActivities();
|
||||
const latestDate =
|
||||
activities.length > 0
|
||||
? new Date(
|
||||
Math.max(
|
||||
...activities.map((a) => {
|
||||
const json = a.toJson();
|
||||
return new Date(
|
||||
json.dateEnd ?? json.dateStart ?? 0,
|
||||
).getTime();
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
coldCheck = checkColdStatus({
|
||||
statusCwId,
|
||||
lastActivityDate: latestDate,
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow status fetched successfully.",
|
||||
{
|
||||
currentStatusId: statusCwId,
|
||||
currentStatus: statusKey,
|
||||
stageName: opportunity.stageName ?? null,
|
||||
isOptimaStage,
|
||||
isTerminal,
|
||||
availableActions: isOptimaStage ? resolvedActions : [],
|
||||
coldCheck,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
console.error("[Workflow Status] Unhandled error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -5,6 +5,11 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
import { cwMembers } from "../../../managers/cwMembers";
|
||||
import {
|
||||
createWorkflowActivity,
|
||||
OptimaType,
|
||||
} from "../../../workflows/wf.opportunity";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -41,6 +46,34 @@ export default createRoute(
|
||||
try {
|
||||
const item = await opportunities.createItem(data);
|
||||
|
||||
// Create a workflow activity for the new opportunity
|
||||
try {
|
||||
const user = c.get("user");
|
||||
let cwMemberId: number | null = null;
|
||||
|
||||
if (user.cwIdentifier) {
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
cwMemberId = cwMember.cwMemberId;
|
||||
}
|
||||
|
||||
if (cwMemberId) {
|
||||
await createWorkflowActivity({
|
||||
name: `[Workflow] Opportunity created — ${item.name}`,
|
||||
opportunityCwId: item.cwOpportunityId,
|
||||
companyCwId: item.companyCwId,
|
||||
assignToCwMemberId: cwMemberId,
|
||||
notes: "Opportunity created.",
|
||||
optimaType: OptimaType.OpportunityCreated,
|
||||
});
|
||||
}
|
||||
} catch (activityErr) {
|
||||
console.error(
|
||||
"[Opportunity Create] Failed to create workflow activity:",
|
||||
activityErr,
|
||||
);
|
||||
// Don't fail the opportunity creation if the activity fails
|
||||
}
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity created successfully!",
|
||||
item.toJson(),
|
||||
|
||||
@@ -1351,6 +1351,35 @@ export class OpportunityController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Product
|
||||
*
|
||||
* Removes a forecast item from this opportunity in ConnectWise,
|
||||
* removes the item ID from the local productSequence, and
|
||||
* invalidates the products cache.
|
||||
*
|
||||
* @param forecastItemId - The CW forecast item ID to delete
|
||||
*/
|
||||
public async deleteProduct(forecastItemId: number): Promise<void> {
|
||||
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);
|
||||
|
||||
// Remove the deleted item from the local product sequence
|
||||
if (this.productSequence.includes(forecastItemId)) {
|
||||
const updatedSequence = this.productSequence.filter(
|
||||
(id) => id !== forecastItemId,
|
||||
);
|
||||
|
||||
await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: { productSequence: updatedSequence },
|
||||
});
|
||||
|
||||
this.productSequence = updatedSequence;
|
||||
}
|
||||
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Procurement Product By Forecast Item
|
||||
*
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fetchAndCacheActivities,
|
||||
fetchAndCacheCompanyCwData,
|
||||
fetchAndCacheOppCwData,
|
||||
invalidateAllOpportunityCaches,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -524,4 +525,42 @@ export const opportunities = {
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Opportunity
|
||||
*
|
||||
* Deletes an opportunity from ConnectWise, removes the local database
|
||||
* record, and invalidates all related Redis caches.
|
||||
*
|
||||
* @param identifier - Internal ID (string) or CW opportunity ID (number)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteItem(identifier: string | number): Promise<void> {
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
const record = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier as string },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete from ConnectWise first
|
||||
await opportunityCw.delete(record.cwOpportunityId);
|
||||
|
||||
// Remove the local DB record
|
||||
await prisma.opportunity.delete({ where: { id: record.id } });
|
||||
|
||||
// Invalidate all related caches
|
||||
await invalidateAllOpportunityCaches(record.cwOpportunityId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export const users = {
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
userId: msData.id,
|
||||
email: msData.mail,
|
||||
email: msData.mail ?? msData.userPrincipalName,
|
||||
name: `${msData.givenName} ${msData.surname}`,
|
||||
login: msData.userPrincipalName,
|
||||
cwIdentifier,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @module algo.coldThreshold
|
||||
*
|
||||
* Cold-Detection Algorithm
|
||||
* ========================
|
||||
*
|
||||
* Determines whether an opportunity has stalled in a status long enough
|
||||
* to be considered "cold". When an opportunity goes cold it is
|
||||
* automatically moved to InternalReview, a system-generated activity is
|
||||
* logged, and it is flagged for the internal review report.
|
||||
*
|
||||
* ## Thresholds (defaults)
|
||||
*
|
||||
* | Status | Stall Threshold |
|
||||
* |-----------------|-----------------|
|
||||
* | QuoteSent | 14 days |
|
||||
* | ConfirmedQuote | 30 days |
|
||||
*
|
||||
* Only these two statuses are eligible for cold detection. All other
|
||||
* statuses return `cold: false`.
|
||||
*
|
||||
* ## How "last activity date" is determined
|
||||
*
|
||||
* The algorithm uses `lastActivityDate` — the most recent of:
|
||||
* - the latest activity's `dateStart`
|
||||
* - the opportunity's `cwLastUpdated`
|
||||
*
|
||||
* The caller is responsible for resolving this value before calling
|
||||
* `checkColdStatus`.
|
||||
*/
|
||||
|
||||
import type { OpportunityController } from "../../controllers/OpportunityController";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stall thresholds in milliseconds, keyed by CW status ID. */
|
||||
export const COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
|
||||
/** QuoteSent — CW status ID 43, "03. Quote Sent" */
|
||||
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
|
||||
|
||||
/** ConfirmedQuote — CW status ID 57, "04. Confirmed Quote" */
|
||||
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ColdCheckInput {
|
||||
/** Current CW status ID of the opportunity. */
|
||||
statusCwId: number | null;
|
||||
|
||||
/**
|
||||
* The most recent meaningful date to measure staleness from.
|
||||
* Typically the latest of the last activity dateStart or cwLastUpdated.
|
||||
*/
|
||||
lastActivityDate: Date | null;
|
||||
|
||||
/** Override for "now" — useful for testing. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface ColdCheckResult {
|
||||
/** Whether the opportunity is considered cold. */
|
||||
cold: boolean;
|
||||
|
||||
/**
|
||||
* Which threshold triggered the cold flag.
|
||||
* `null` when `cold` is `false`.
|
||||
*/
|
||||
triggeredBy: {
|
||||
statusCwId: number;
|
||||
statusName: string;
|
||||
thresholdDays: number;
|
||||
staleDays: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_NAMES: Record<number, string> = {
|
||||
43: "QuoteSent",
|
||||
57: "ConfirmedQuote",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate whether an opportunity has exceeded its cold-stall threshold.
|
||||
*
|
||||
* @returns A `ColdCheckResult` indicating cold status and trigger metadata.
|
||||
*/
|
||||
export function checkColdStatus(_input: ColdCheckInput): ColdCheckResult {
|
||||
// Bypassed — always returns not-cold until cold-stall feature is ready
|
||||
return { cold: false, triggeredBy: null };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @module algo.followUpScheduler
|
||||
*
|
||||
* Follow-Up Scheduling Algorithm
|
||||
* ===============================
|
||||
*
|
||||
* Determines the due date for follow-up activities created by the
|
||||
* opportunity workflow. The follow-up is always assigned to the user
|
||||
* who triggered its creation.
|
||||
*
|
||||
* ## TODO — Calendar-aware scheduling
|
||||
*
|
||||
* This module currently uses a **dummy algorithm** that schedules the
|
||||
* follow-up for the next business day at 10:00 AM local time.
|
||||
*
|
||||
* It needs to be replaced with an availability-aware algorithm that:
|
||||
* 1. Reads the assigned user's calendar (Microsoft Graph / CW schedule).
|
||||
* 2. Finds the earliest open slot of sufficient duration.
|
||||
* 3. Respects company-wide blackout dates (holidays, company events).
|
||||
* 4. Accounts for the user's working-hours preferences.
|
||||
*
|
||||
* Until that integration is complete, the simple "next business day"
|
||||
* heuristic is used as a placeholder.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FollowUpScheduleInput {
|
||||
/** The user who triggered the activity (follow-up is assigned to them). */
|
||||
triggeredByUserId: string;
|
||||
|
||||
/** Optional override for "now" — useful for testing. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface FollowUpScheduleResult {
|
||||
/** Suggested due date for the follow-up activity. */
|
||||
dueDate: Date;
|
||||
|
||||
/** ISO string version for CW API payloads. */
|
||||
dueDateIso: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schedule a follow-up activity.
|
||||
*
|
||||
* Returns a suggested `dueDate` for the follow-up activity.
|
||||
* Currently uses dummy logic: next business day at 10:00 AM.
|
||||
*
|
||||
* @param input - Scheduling parameters
|
||||
* @returns The scheduled follow-up date
|
||||
*/
|
||||
export function scheduleFollowUp(
|
||||
input: FollowUpScheduleInput,
|
||||
): FollowUpScheduleResult {
|
||||
const now = input.now ?? new Date();
|
||||
const dueDate = getNextBusinessDay(now);
|
||||
|
||||
// Set to 10:00 AM
|
||||
dueDate.setHours(10, 0, 0, 0);
|
||||
|
||||
return {
|
||||
dueDate,
|
||||
dueDateIso: dueDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the next business day (Mon–Fri) from the given date.
|
||||
* If the given date is already a weekday before 10 AM, returns
|
||||
* the NEXT business day (not the same day).
|
||||
*/
|
||||
function getNextBusinessDay(from: Date): Date {
|
||||
const result = new Date(from);
|
||||
|
||||
// Always advance at least one day
|
||||
result.setDate(result.getDate() + 1);
|
||||
|
||||
const day = result.getDay();
|
||||
|
||||
// Saturday → Monday (+2)
|
||||
if (day === 6) result.setDate(result.getDate() + 2);
|
||||
// Sunday → Monday (+1)
|
||||
if (day === 0) result.setDate(result.getDate() + 1);
|
||||
|
||||
return result;
|
||||
}
|
||||
+18
@@ -499,6 +499,24 @@ export async function invalidateProductsCache(
|
||||
await redis.del(productsCacheKey(cwOpportunityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached data for an opportunity.
|
||||
*
|
||||
* Removes activities, notes, contacts, products, and CW data cache keys.
|
||||
* Call this when an opportunity is deleted.
|
||||
*/
|
||||
export async function invalidateAllOpportunityCaches(
|
||||
cwOpportunityId: number,
|
||||
): Promise<void> {
|
||||
await redis.del(
|
||||
activityCacheKey(cwOpportunityId),
|
||||
notesCacheKey(cwOpportunityId),
|
||||
contactsCacheKey(cwOpportunityId),
|
||||
productsCacheKey(cwOpportunityId),
|
||||
oppCwDataCacheKey(cwOpportunityId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site TTL — 20 minutes. Site/address data rarely changes so we cache
|
||||
* aggressively. The background refresh does NOT proactively warm site keys;
|
||||
|
||||
@@ -295,6 +295,31 @@ export const opportunityCw = {
|
||||
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Forecast Item
|
||||
*
|
||||
* Removes a forecast item from an opportunity by PUTting the forecast
|
||||
* without the target item. CW's forecast endpoint replaces the entire
|
||||
* forecast items list on PUT.
|
||||
*/
|
||||
deleteProduct: async (
|
||||
opportunityId: number,
|
||||
forecastItemId: number,
|
||||
): Promise<void> => {
|
||||
const forecast = await opportunityCw.fetchProducts(opportunityId);
|
||||
const items = forecast.forecastItems ?? [];
|
||||
|
||||
const filtered = items.filter((fi) => fi.id !== forecastItemId);
|
||||
if (filtered.length === items.length) {
|
||||
throw new Error(
|
||||
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
await connectWiseApi.put(url, { ...forecast, forecastItems: filtered });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Notes
|
||||
*
|
||||
@@ -463,4 +488,13 @@ export const opportunityCw = {
|
||||
);
|
||||
return response.data as CWProcurementProduct;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Opportunity
|
||||
*
|
||||
* Deletes an opportunity from ConnectWise by its CW opportunity ID.
|
||||
*/
|
||||
delete: async (opportunityId: number): Promise<void> => {
|
||||
await connectWiseApi.delete(`/sales/opportunities/${opportunityId}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -213,7 +213,6 @@ export interface CWForecastItemCreate {
|
||||
catalogItem?: { id: number };
|
||||
forecastDescription?: string;
|
||||
productDescription?: string;
|
||||
customerDescription?: string;
|
||||
quantity?: number;
|
||||
status?: { id: number };
|
||||
productClass?: string;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @module cw.opportunityService
|
||||
*
|
||||
* ConnectWise Opportunity Service
|
||||
* ================================
|
||||
*
|
||||
* Methods for ConnectWise integrations that the opportunity workflow
|
||||
* calls. Some are still stubs (marked with console.warn); others are
|
||||
* fully implemented against the CW REST API.
|
||||
*/
|
||||
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { connectWiseApi } from "../constants";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TimeEntryInput {
|
||||
/** CW activity ID to charge the time entry to. */
|
||||
activityId: number;
|
||||
/** CW member ID of the user submitting time. */
|
||||
cwMemberId: number;
|
||||
/** ISO-8601 datetime when work started. */
|
||||
timeStart: string;
|
||||
/** ISO-8601 datetime when work ended. */
|
||||
timeEnd: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface TimeEntryResult {
|
||||
success: boolean;
|
||||
cwTimeEntryId: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StatusSyncInput {
|
||||
opportunityId: number;
|
||||
statusCwId: number;
|
||||
}
|
||||
|
||||
export interface StatusSyncResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Submit a time entry to ConnectWise for an opportunity activity.
|
||||
*
|
||||
* Called automatically whenever `timeStart` and `timeEnd` are provided
|
||||
* on a workflow action.
|
||||
*/
|
||||
export async function submitTimeEntry(
|
||||
input: TimeEntryInput,
|
||||
): Promise<TimeEntryResult> {
|
||||
try {
|
||||
const stripMs = (d: string) => d.replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
const response = await connectWiseApi.post("/time/entries", {
|
||||
member: { id: input.cwMemberId },
|
||||
chargeToType: "Activity",
|
||||
chargeToId: input.activityId,
|
||||
timeStart: stripMs(input.timeStart),
|
||||
timeEnd: stripMs(input.timeEnd),
|
||||
notes: input.notes,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
cwTimeEntryId: response.data?.id ?? null,
|
||||
message: `Time entry ${response.data?.id} created for activity ${input.activityId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[cw.opportunityService] submitTimeEntry FAILED — activityId=${input.activityId}, cwMemberId=${input.cwMemberId}`,
|
||||
error?.response?.data ?? error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
cwTimeEntryId: null,
|
||||
message: `Failed to submit time entry: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an opportunity's status to ConnectWise.
|
||||
*
|
||||
* Called whenever the workflow transitions an opportunity to a new
|
||||
* status, ensuring the CW record stays in sync.
|
||||
*/
|
||||
export async function syncOpportunityStatus(
|
||||
input: StatusSyncInput,
|
||||
): Promise<StatusSyncResult> {
|
||||
try {
|
||||
await opportunityCw.update(input.opportunityId, {
|
||||
status: { id: input.statusCwId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Opportunity ${input.opportunityId} status synced to CW status ID ${input.statusCwId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[cw.opportunityService] syncOpportunityStatus FAILED — opportunityId=${input.opportunityId}, statusCwId=${input.statusCwId}`,
|
||||
error?.response?.data ?? error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync opportunity status: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,12 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/companies/[id]/configurations.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
{
|
||||
node: "company.fetch.sites",
|
||||
description: "Fetch company sites from ConnectWise",
|
||||
usedIn: ["src/api/companies/[id]/sites.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -435,6 +441,13 @@ export const PERMISSION_NODES = {
|
||||
description: "Create a new opportunity in ConnectWise",
|
||||
usedIn: ["src/api/sales/opportunities/create.ts"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.delete",
|
||||
description:
|
||||
"Delete an opportunity from ConnectWise and the local database",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.create",
|
||||
description: "Create a new note on an opportunity",
|
||||
@@ -464,6 +477,12 @@ export const PERMISSION_NODES = {
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.delete",
|
||||
description: "Delete a product (forecast item) from an opportunity",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/products/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.add",
|
||||
description:
|
||||
@@ -557,6 +576,75 @@ export const PERMISSION_NODES = {
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.view_profit",
|
||||
description:
|
||||
"View profit data on opportunity products. Controls visibility of profit values in the UI.",
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.finalize",
|
||||
description:
|
||||
"Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead.",
|
||||
usedIn: [
|
||||
"src/workflows/wf.opportunity.ts",
|
||||
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.cancel",
|
||||
description:
|
||||
"Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status.",
|
||||
usedIn: [
|
||||
"src/workflows/wf.opportunity.ts",
|
||||
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.review",
|
||||
description:
|
||||
"Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status.",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.send",
|
||||
description:
|
||||
"Send a quote to the customer. Required to transition an opportunity to QuoteSent (and its compound transitions like immediate won/lost/confirmed).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.reopen",
|
||||
description:
|
||||
"Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active.",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.win",
|
||||
description:
|
||||
"Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.lose",
|
||||
description:
|
||||
"Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.workflow",
|
||||
description:
|
||||
"Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/workflow/dispatch.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
+174
-12
@@ -18,7 +18,7 @@ export interface QuoteStatus {
|
||||
|
||||
export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
//
|
||||
// FUTURE
|
||||
// FUTURE LEAD
|
||||
//
|
||||
{
|
||||
id: 51,
|
||||
@@ -41,6 +41,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING NEW
|
||||
//
|
||||
{
|
||||
id: 37,
|
||||
name: "Pending New",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2022-04-28T21:04:53Z",
|
||||
_info: {
|
||||
lastUpdated: "2022-04-28T21:06:24Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "f90e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// NEW
|
||||
//
|
||||
@@ -62,7 +83,6 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
optimaEquivalency: [
|
||||
1, // Pre2021-1) New
|
||||
13, // Pre2021-Initial Contact Made
|
||||
37, // 00. Pending New
|
||||
],
|
||||
},
|
||||
|
||||
@@ -90,6 +110,53 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
27, // Z4. Waiting-VendorInfo
|
||||
28, // Z5. Waiting-OtherTTStaff
|
||||
41, // PRE2405. Review Ready
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// QUOTE SENT
|
||||
//
|
||||
{
|
||||
id: 43,
|
||||
name: "Quote Sent",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2022-04-28T21:06:02Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:06:55Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "ff0e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
17, // Pre2021-5) Quote Sent
|
||||
25, // ZOLD---Quote Sent
|
||||
55, // PRE24_70. Quote Sent - Sell
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// CONFIRMED QUOTE
|
||||
//
|
||||
{
|
||||
id: 57,
|
||||
name: "Confirmed Quote",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2024-04-28T15:07:11Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:07:11Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "0d0f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
54, // PRE24_90. Customer Approved
|
||||
],
|
||||
},
|
||||
@@ -116,14 +183,10 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
9, // Pre2021-Recommendation
|
||||
15, // Pre2021-3) Onsite Assess Sch'd
|
||||
16, // Pre2021-4) Quote Info Gathered
|
||||
17, // Pre2021-5) Quote Sent
|
||||
18, // Pre2021-6) Follow-up #1 Made
|
||||
19, // Pre2021-7) Follow-up #2 Made
|
||||
20, // Pre2021-8) Follow-up #3 Made
|
||||
|
||||
25, // ZOLD---Quote Sent
|
||||
43, // 03. Quote Sent
|
||||
|
||||
38, // PRE2402. On-Site Ready
|
||||
39, // PRE2403. On-Site Scheduled
|
||||
40, // PRE2404. On-Site Complete
|
||||
@@ -134,11 +197,72 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
47, // PRE2412. Follow-Up3
|
||||
48, // PRE2413. Follow-Up Extended
|
||||
52, // PRE2489. Overdue
|
||||
55, // PRE24_70. Quote Sent - Sell
|
||||
57, // 04. Confirmed Quote
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING SENT
|
||||
//
|
||||
{
|
||||
id: 60,
|
||||
name: "Pending Sent",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2026-03-08T23:06:01Z",
|
||||
_info: {
|
||||
lastUpdated: "2026-03-08T23:06:01Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "c900215f-431b-f111-b2ee-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING REVISION
|
||||
//
|
||||
{
|
||||
id: 61,
|
||||
name: "Pending Revision",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2026-03-08T23:06:06Z",
|
||||
_info: {
|
||||
lastUpdated: "2026-03-08T23:06:06Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "ca00215f-431b-f111-b2ee-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING WON
|
||||
//
|
||||
{
|
||||
id: 49,
|
||||
name: "Pending Won",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2023-02-08T21:27:35Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-01-21T20:39:47Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "050f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// WON
|
||||
//
|
||||
@@ -159,11 +283,30 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
connectWiseId: "f10e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
2, // Pre2021-8) Won
|
||||
54, // PRE24_90. Customer Approved (if you treat as effectively Won)
|
||||
49, // 91. Pending Won
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING LOST
|
||||
//
|
||||
{
|
||||
id: 50,
|
||||
name: "Pending Lost",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2023-02-08T21:32:10Z",
|
||||
_info: {
|
||||
lastUpdated: "2023-02-08T21:32:41Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "060f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// LOST
|
||||
//
|
||||
@@ -192,8 +335,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
32, // Pre2024_99. Lost-NoDecision
|
||||
33, // Pre2024_99. Lost-Pricing
|
||||
34, // Pre2024_99. Lost-OtherTTQuote
|
||||
|
||||
50, // 98. Pending Lost
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// CANCELED
|
||||
//
|
||||
{
|
||||
id: 59,
|
||||
name: "Canceled",
|
||||
wonFlag: false,
|
||||
lostFlag: true,
|
||||
closedFlag: true,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2026-03-08T22:45:07Z",
|
||||
_info: {
|
||||
lastUpdated: "2026-03-08T22:45:07Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "83ddc173-401b-f111-b2ee-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+107
@@ -17,6 +17,45 @@ const { privateKey: _testPrivateKey, publicKey: _testPublicKey } =
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the globalEvents module — many source files import `events` from here.
|
||||
// We provide both `events` and `setupEventDebugger` so that no test
|
||||
// encounters "export not found" when another test's mock.module is stale.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
mock.module("../src/modules/globalEvents", () => ({
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
off: mock(),
|
||||
once: mock(),
|
||||
removeAllListeners: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock modules that are commonly mocked by test files at top-level.
|
||||
// Having them in the preload ensures that even when per-test mock.module
|
||||
// calls persist globally, the baseline mock is complete.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
mock.module("../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
mock.module("../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the constants module — almost every source file imports from here.
|
||||
// We provide safe defaults so modules can be imported without side-effects.
|
||||
@@ -60,6 +99,74 @@ mock.module("../src/constants", () => ({
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a complete mock constants object for use with `mock.module()`.
|
||||
*
|
||||
* Pass `overrides` to replace specific exports (e.g. a custom prisma mock).
|
||||
* All keys from the preload mock are included so that downstream modules
|
||||
* importing named exports (secureValuesPublicKey, connectWiseApi, etc.)
|
||||
* never encounter "export not found" errors.
|
||||
*/
|
||||
export function buildMockConstants(
|
||||
overrides: Record<string, any> = {},
|
||||
): Record<string, any> {
|
||||
return {
|
||||
prisma: createMockPrisma(),
|
||||
PORT: "3333",
|
||||
API_BASE_URL: "http://localhost:3333",
|
||||
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||
accessTokenDuration: "10min",
|
||||
refreshTokenDuration: "30d",
|
||||
accessTokenPrivateKey: _testPrivateKey,
|
||||
refreshTokenPrivateKey: _testPrivateKey,
|
||||
permissionsPrivateKey: _testPrivateKey,
|
||||
secureValuesPrivateKey: _testPrivateKey,
|
||||
secureValuesPublicKey: _testPublicKey,
|
||||
msalClient: { acquireTokenByCode: mock(() => Promise.resolve({})) },
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
post: mock(() => Promise.resolve({ data: {} })),
|
||||
put: mock(() => Promise.resolve({ data: {} })),
|
||||
patch: mock(() => Promise.resolve({ data: {} })),
|
||||
delete: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
unifi: createMockUnifi(),
|
||||
unifiControllerBaseUrl: "https://unifi.test.local",
|
||||
unifiSite: "default",
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "test-pass",
|
||||
io: { of: mock(() => ({ on: mock() })) },
|
||||
engine: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete mock globalEvents object for use with `mock.module()`.
|
||||
* Includes both `events` and `setupEventDebugger` so downstream modules
|
||||
* never encounter "export not found" errors.
|
||||
*/
|
||||
export function buildMockGlobalEvents(
|
||||
overrides: Record<string, any> = {},
|
||||
): Record<string, any> {
|
||||
return {
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
off: mock(),
|
||||
once: mock(),
|
||||
removeAllListeners: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockPrisma() {
|
||||
const createModelProxy = () =>
|
||||
new Proxy(
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCWActivity } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory (same pattern as generatedQuotesManager.test.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("activities manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchItem
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchItem()", () => {
|
||||
test("returns an ActivityController on success", async () => {
|
||||
const cwData = buildMockCWActivity();
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetch: mock(() => Promise.resolve(cwData)),
|
||||
fetchByCompany: mock(() => Promise.resolve([])),
|
||||
fetchByOpportunity: mock(() => Promise.resolve([])),
|
||||
delete: mock(() => Promise.resolve()),
|
||||
countItems: mock(() => Promise.resolve(0)),
|
||||
update: mock(() => Promise.resolve(cwData)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
post: mock(() => Promise.resolve({ data: {} })),
|
||||
patch: mock(() => Promise.resolve({ data: {} })),
|
||||
delete: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchItem(5001);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.cwActivityId).toBe(5001);
|
||||
});
|
||||
|
||||
test("throws GenericError on failure", async () => {
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetch: mock(() => Promise.reject(new Error("CW API down"))),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
try {
|
||||
await activities.fetchItem(9999);
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("FetchActivityError");
|
||||
expect(e.status).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns array of ActivityControllers", async () => {
|
||||
const cwData = [buildMockCWActivity(), buildMockCWActivity({ id: 5002 })];
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: cwData })),
|
||||
post: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
|
||||
test("clamps page to minimum 1", async () => {
|
||||
const getMock = mock(() => Promise.resolve({ data: [] }));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: { get: getMock },
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
await activities.fetchPages(-5, 10);
|
||||
const url = getMock.mock.calls[0]?.[0] as string;
|
||||
expect(url).toContain("page=1");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByCompany()", () => {
|
||||
test("returns ActivityControllers for a company", async () => {
|
||||
const items = [buildMockCWActivity()];
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByCompany: mock(() => Promise.resolve(items)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchByCompany(123);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByOpportunity
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByOpportunity()", () => {
|
||||
test("returns ActivityControllers for an opportunity", async () => {
|
||||
const items = [buildMockCWActivity()];
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunity: mock(() => Promise.resolve(items)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.fetchByOpportunity(1001);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// delete
|
||||
// -------------------------------------------------------------------
|
||||
describe("delete()", () => {
|
||||
test("delegates to activityCw.delete", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve());
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: { delete: deleteMock },
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
await activities.delete(5001);
|
||||
expect(deleteMock).toHaveBeenCalledWith(5001);
|
||||
});
|
||||
|
||||
test("throws GenericError on failure", async () => {
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
delete: mock(() => Promise.reject(new Error("fail"))),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
try {
|
||||
await activities.delete(9999);
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("DeleteActivityError");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns count from activityCw", async () => {
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
countItems: mock(() => Promise.resolve(42)),
|
||||
},
|
||||
}));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock(),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { activities } = await import("../../src/managers/activities");
|
||||
const result = await activities.count();
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Tests for src/modules/algorithms/algo.coldThreshold.ts
|
||||
*
|
||||
* checkColdStatus is currently bypassed (always returns not-cold).
|
||||
* COLD_THRESHOLDS config is still tested.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
checkColdStatus,
|
||||
COLD_THRESHOLDS,
|
||||
} from "../../src/modules/algorithms/algo.coldThreshold";
|
||||
|
||||
describe("COLD_THRESHOLDS", () => {
|
||||
test("defines thresholds for QuoteSent (43) and ConfirmedQuote (57)", () => {
|
||||
expect(COLD_THRESHOLDS[43]).toBeDefined();
|
||||
expect(COLD_THRESHOLDS[43].days).toBe(14);
|
||||
expect(COLD_THRESHOLDS[57]).toBeDefined();
|
||||
expect(COLD_THRESHOLDS[57].days).toBe(30);
|
||||
});
|
||||
|
||||
test("ms values match day values", () => {
|
||||
expect(COLD_THRESHOLDS[43].ms).toBe(14 * 24 * 60 * 60 * 1000);
|
||||
expect(COLD_THRESHOLDS[57].ms).toBe(30 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkColdStatus (bypassed)", () => {
|
||||
test("always returns not-cold regardless of input", () => {
|
||||
// With null status
|
||||
expect(checkColdStatus({ statusCwId: null, lastActivityDate: new Date() }))
|
||||
.toEqual({ cold: false, triggeredBy: null });
|
||||
|
||||
// With eligible status and stale activity
|
||||
const now = new Date("2026-03-16T00:00:00Z");
|
||||
const lastActivity = new Date("2026-03-01T00:00:00Z"); // 15 days ago
|
||||
expect(checkColdStatus({ statusCwId: 43, lastActivityDate: lastActivity, now }))
|
||||
.toEqual({ cold: false, triggeredBy: null });
|
||||
|
||||
// With ConfirmedQuote exceeding threshold
|
||||
expect(checkColdStatus({ statusCwId: 57, lastActivityDate: new Date("2026-02-01"), now: new Date("2026-04-01") }))
|
||||
.toEqual({ cold: false, triggeredBy: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Tests for src/modules/algorithms/algo.followUpScheduler.ts
|
||||
*
|
||||
* Pure function — no mocking needed.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { scheduleFollowUp } from "../../src/modules/algorithms/algo.followUpScheduler";
|
||||
|
||||
describe("scheduleFollowUp", () => {
|
||||
test("returns dueDate and dueDateIso", () => {
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-02T14:00:00Z"), // Monday
|
||||
});
|
||||
expect(result.dueDate).toBeInstanceOf(Date);
|
||||
expect(typeof result.dueDateIso).toBe("string");
|
||||
});
|
||||
|
||||
test("schedules for next day at 10 AM on a weekday (Mon → Tue)", () => {
|
||||
// Monday March 2, 2026
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-02T14:00:00Z"),
|
||||
});
|
||||
// Should be Tuesday March 3, 2026 at 10:00 AM local
|
||||
expect(result.dueDate.getDate()).toBe(3);
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
expect(result.dueDate.getMinutes()).toBe(0);
|
||||
expect(result.dueDate.getSeconds()).toBe(0);
|
||||
});
|
||||
|
||||
test("Friday → Monday (skips weekend)", () => {
|
||||
// Friday March 6, 2026
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-06T14:00:00Z"),
|
||||
});
|
||||
// Next day is Saturday (day 6), should skip to Monday
|
||||
// March 6 (Fri) +1 = March 7 (Sat) → +2 → March 9 (Mon)
|
||||
expect(result.dueDate.getDay()).toBe(1); // Monday
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
});
|
||||
|
||||
test("Saturday → Monday", () => {
|
||||
// Saturday March 7, 2026
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-07T14:00:00Z"),
|
||||
});
|
||||
// +1 = Sunday (day 0) → +1 → Monday
|
||||
expect(result.dueDate.getDay()).toBe(1); // Monday
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
});
|
||||
|
||||
test("defaults to current time when now is not provided", () => {
|
||||
const result = scheduleFollowUp({ triggeredByUserId: "user-1" });
|
||||
expect(result.dueDate).toBeInstanceOf(Date);
|
||||
// Due date should be in the future
|
||||
expect(result.dueDate.getTime()).toBeGreaterThan(Date.now() - 1000);
|
||||
});
|
||||
|
||||
test("dueDate always has time set to 10:00:00.000", () => {
|
||||
// Test across several days of the week
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date(`2026-03-0${d}T08:00:00Z`),
|
||||
});
|
||||
expect(result.dueDate.getHours()).toBe(10);
|
||||
expect(result.dueDate.getMinutes()).toBe(0);
|
||||
expect(result.dueDate.getSeconds()).toBe(0);
|
||||
expect(result.dueDate.getMilliseconds()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("dueDateIso is a valid ISO string of the dueDate", () => {
|
||||
const result = scheduleFollowUp({
|
||||
triggeredByUserId: "user-1",
|
||||
now: new Date("2026-03-02T14:00:00Z"),
|
||||
});
|
||||
expect(new Date(result.dueDateIso).getTime()).toBe(
|
||||
result.dueDate.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCompany } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("companies manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns company count from Prisma", async () => {
|
||||
const countMock = mock(() => Promise.resolve(5));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.count();
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns paginated companies", async () => {
|
||||
const mockData = [
|
||||
buildMockCompany(),
|
||||
buildMockCompany({ id: "company-2" }),
|
||||
];
|
||||
const findManyMock = mock(() => Promise.resolve(mockData));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
findMany: findManyMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
|
||||
test("uses correct skip for page 1", async () => {
|
||||
const findManyMock = mock(() => Promise.resolve([]));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
findMany: findManyMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
await companies.fetchPages(1, 20);
|
||||
expect(findManyMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// search
|
||||
// -------------------------------------------------------------------
|
||||
describe("search()", () => {
|
||||
test("returns matching companies", async () => {
|
||||
const mockData = [buildMockCompany()];
|
||||
const findManyMock = mock(() => Promise.resolve(mockData));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: {
|
||||
findMany: findManyMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.search("Test", 1, 10);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("throws when company not found in DB", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: { findFirst: mock(() => Promise.resolve(null)) },
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
try {
|
||||
await companies.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe("Unknown company.");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns CompanyController on success", async () => {
|
||||
const dbCompany = buildMockCompany();
|
||||
const cwCompanyData = {
|
||||
defaultContact: { _info: { contact_href: "/contacts/1" } },
|
||||
_info: { contacts_href: "/contacts?page=1" },
|
||||
};
|
||||
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
company: { findFirst: mock(() => Promise.resolve(dbCompany)) },
|
||||
}),
|
||||
connectWiseApi: {
|
||||
get: mock(() =>
|
||||
Promise.resolve({
|
||||
data: cwCompanyData,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
const { companies } = await import("../../src/managers/companies");
|
||||
const result = await companies.fetch("company-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("company-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,14 +20,19 @@ describe("computeProductsCacheTTL", () => {
|
||||
});
|
||||
|
||||
// -- Won/Lost status set ------------------------------------------------
|
||||
test("WON_LOST_STATUS_IDS contains Won canonical ID (29) and Pending Won (49)", () => {
|
||||
test("WON_LOST_STATUS_IDS contains Won canonical ID (29)", () => {
|
||||
expect(WON_LOST_STATUS_IDS.has(29)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(49)).toBe(true);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Pending Lost (50)", () => {
|
||||
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Canceled (59)", () => {
|
||||
expect(WON_LOST_STATUS_IDS.has(53)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(50)).toBe(true);
|
||||
expect(WON_LOST_STATUS_IDS.has(59)).toBe(true);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS does not contain Pending Won (49) or Pending Lost (50)", () => {
|
||||
// Pending Won/Lost do not have wonFlag/lostFlag set in QuoteStatuses
|
||||
expect(WON_LOST_STATUS_IDS.has(49)).toBe(false);
|
||||
expect(WON_LOST_STATUS_IDS.has(50)).toBe(false);
|
||||
});
|
||||
|
||||
test("WON_LOST_STATUS_IDS does not contain Active (58) or New (24)", () => {
|
||||
@@ -48,7 +53,9 @@ describe("computeProductsCacheTTL", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for Pending Won status (CW ID 49)", () => {
|
||||
test("returns PRODUCTS_TTL_HOT for Pending Won status (CW ID 49) with recent activity", () => {
|
||||
// Pending Won is not in WON_LOST_STATUS_IDS (no wonFlag), so it falls
|
||||
// through to the activity-based rules.
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 49,
|
||||
closedFlag: false,
|
||||
@@ -57,7 +64,7 @@ describe("computeProductsCacheTTL", () => {
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||
});
|
||||
|
||||
test("returns null for Lost status (CW ID 53)", () => {
|
||||
@@ -72,7 +79,9 @@ describe("computeProductsCacheTTL", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for Pending Lost status (CW ID 50)", () => {
|
||||
test("returns PRODUCTS_TTL_HOT for Pending Lost status (CW ID 50) with recent activity", () => {
|
||||
// Pending Lost is not in WON_LOST_STATUS_IDS (no lostFlag), so it falls
|
||||
// through to the activity-based rules.
|
||||
const result = computeProductsCacheTTL({
|
||||
statusCwId: 50,
|
||||
closedFlag: false,
|
||||
@@ -81,7 +90,7 @@ describe("computeProductsCacheTTL", () => {
|
||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||
});
|
||||
|
||||
// -- Rule 2: Opp not cacheable → null ----------------------------------
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { CwMemberController } from "../../../src/controllers/CwMemberController";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildMockCwMember(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: "member-1",
|
||||
cwMemberId: 42,
|
||||
identifier: "jdoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
officeEmail: "jdoe@example.com",
|
||||
inactiveFlag: false,
|
||||
apiKey: null,
|
||||
cwLastUpdated: new Date("2026-02-01"),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-02-01"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CwMemberController", () => {
|
||||
// -----------------------------------------------------------------
|
||||
// Constructor
|
||||
// -----------------------------------------------------------------
|
||||
describe("constructor", () => {
|
||||
test("sets all public properties from data", () => {
|
||||
const data = buildMockCwMember();
|
||||
const ctrl = new CwMemberController(data as any);
|
||||
|
||||
expect(ctrl.id).toBe("member-1");
|
||||
expect(ctrl.cwMemberId).toBe(42);
|
||||
expect(ctrl.identifier).toBe("jdoe");
|
||||
expect(ctrl.firstName).toBe("John");
|
||||
expect(ctrl.lastName).toBe("Doe");
|
||||
expect(ctrl.officeEmail).toBe("jdoe@example.com");
|
||||
expect(ctrl.inactiveFlag).toBe(false);
|
||||
expect(ctrl.apiKey).toBeNull();
|
||||
expect(ctrl.cwLastUpdated).toEqual(new Date("2026-02-01"));
|
||||
expect(ctrl.createdAt).toEqual(new Date("2026-01-01"));
|
||||
expect(ctrl.updatedAt).toEqual(new Date("2026-02-01"));
|
||||
});
|
||||
|
||||
test("handles null officeEmail", () => {
|
||||
const data = buildMockCwMember({ officeEmail: null });
|
||||
const ctrl = new CwMemberController(data as any);
|
||||
expect(ctrl.officeEmail).toBeNull();
|
||||
});
|
||||
|
||||
test("handles apiKey set", () => {
|
||||
const data = buildMockCwMember({ apiKey: "secret-key" });
|
||||
const ctrl = new CwMemberController(data as any);
|
||||
expect(ctrl.apiKey).toBe("secret-key");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// fullName getter
|
||||
// -----------------------------------------------------------------
|
||||
describe("fullName", () => {
|
||||
test("returns firstName + lastName", () => {
|
||||
const ctrl = new CwMemberController(buildMockCwMember() as any);
|
||||
expect(ctrl.fullName).toBe("John Doe");
|
||||
});
|
||||
|
||||
test("returns trimmed name when lastName is empty", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ lastName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.fullName).toBe("John");
|
||||
});
|
||||
|
||||
test("returns trimmed name when firstName is empty", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ firstName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.fullName).toBe("Doe");
|
||||
});
|
||||
|
||||
test("falls back to identifier when both names are empty", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ firstName: "", lastName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.fullName).toBe("jdoe");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// mapCwToDb (static)
|
||||
// -----------------------------------------------------------------
|
||||
describe("mapCwToDb", () => {
|
||||
test("maps CW member fields to DB schema", () => {
|
||||
const cwItem = {
|
||||
identifier: "jdoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
officeEmail: "jdoe@example.com",
|
||||
inactiveFlag: false,
|
||||
_info: { lastUpdated: "2026-02-01T12:00:00Z" },
|
||||
};
|
||||
|
||||
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||
expect(result.identifier).toBe("jdoe");
|
||||
expect(result.firstName).toBe("John");
|
||||
expect(result.lastName).toBe("Doe");
|
||||
expect(result.officeEmail).toBe("jdoe@example.com");
|
||||
expect(result.inactiveFlag).toBe(false);
|
||||
expect(result.cwLastUpdated).toEqual(new Date("2026-02-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
test("handles null/missing fields with defaults", () => {
|
||||
const cwItem = {
|
||||
identifier: "empty",
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
officeEmail: null,
|
||||
inactiveFlag: null,
|
||||
_info: null,
|
||||
};
|
||||
|
||||
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||
expect(result.firstName).toBe("");
|
||||
expect(result.lastName).toBe("");
|
||||
expect(result.officeEmail).toBeNull();
|
||||
expect(result.inactiveFlag).toBe(false);
|
||||
expect(result.cwLastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("handles undefined _info.lastUpdated", () => {
|
||||
const cwItem = {
|
||||
identifier: "test",
|
||||
firstName: "A",
|
||||
lastName: "B",
|
||||
officeEmail: null,
|
||||
inactiveFlag: false,
|
||||
_info: {},
|
||||
};
|
||||
|
||||
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||
// Without lastUpdated, falls through to new Date()
|
||||
expect(result.cwLastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// toJson
|
||||
// -----------------------------------------------------------------
|
||||
describe("toJson", () => {
|
||||
test("returns all fields including fullName", () => {
|
||||
const ctrl = new CwMemberController(buildMockCwMember() as any);
|
||||
const json = ctrl.toJson();
|
||||
|
||||
expect(json.id).toBe("member-1");
|
||||
expect(json.cwMemberId).toBe(42);
|
||||
expect(json.identifier).toBe("jdoe");
|
||||
expect(json.firstName).toBe("John");
|
||||
expect(json.lastName).toBe("Doe");
|
||||
expect(json.fullName).toBe("John Doe");
|
||||
expect(json.officeEmail).toBe("jdoe@example.com");
|
||||
expect(json.inactiveFlag).toBe(false);
|
||||
expect(json.apiKey).toBeNull();
|
||||
expect(json.cwLastUpdated).toEqual(new Date("2026-02-01"));
|
||||
expect(json.createdAt).toEqual(new Date("2026-01-01"));
|
||||
expect(json.updatedAt).toEqual(new Date("2026-02-01"));
|
||||
});
|
||||
|
||||
test("includes fullName in JSON", () => {
|
||||
const ctrl = new CwMemberController(
|
||||
buildMockCwMember({ firstName: "", lastName: "" }) as any,
|
||||
);
|
||||
expect(ctrl.toJson().fullName).toBe("jdoe");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCredentialType, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("credentialTypes manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns CredentialTypeController when found", async () => {
|
||||
const mockData = { ...buildMockCredentialType(), credentials: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
const result = await credentialTypes.fetch("ctype-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("ctype-1");
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
try {
|
||||
await credentialTypes.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CredentialTypeNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAll
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAll()", () => {
|
||||
test("returns array of controllers", async () => {
|
||||
const items = [
|
||||
{ ...buildMockCredentialType(), credentials: [] },
|
||||
{
|
||||
...buildMockCredentialType({ id: "ctype-2", name: "API Key" }),
|
||||
credentials: [],
|
||||
},
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
const result = await credentialTypes.fetchAll();
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// create
|
||||
// -------------------------------------------------------------------
|
||||
describe("create()", () => {
|
||||
test("creates and returns a CredentialTypeController", async () => {
|
||||
const created = {
|
||||
...buildMockCredentialType(),
|
||||
credentials: [],
|
||||
};
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(null)), // no dupe
|
||||
create: mock(() => Promise.resolve(created)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
const result = await credentialTypes.create({
|
||||
name: "Login Credential",
|
||||
permissionScope: "credential.login",
|
||||
fields: [],
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe("Login Credential");
|
||||
});
|
||||
|
||||
test("throws when name already exists", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
findFirst: mock(() => Promise.resolve(buildMockCredentialType())),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
try {
|
||||
await credentialTypes.create({
|
||||
name: "Login Credential",
|
||||
permissionScope: "credential.login",
|
||||
fields: [],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CredentialTypeAlreadyExists");
|
||||
expect(e.status).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// delete
|
||||
// -------------------------------------------------------------------
|
||||
describe("delete()", () => {
|
||||
test("deletes credential type by id", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve({}));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credentialType: {
|
||||
delete: deleteMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentialTypes } =
|
||||
await import("../../src/managers/credentialTypes");
|
||||
await credentialTypes.delete("ctype-1");
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "ctype-1" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import {
|
||||
buildMockCredential,
|
||||
buildMockCredentialType,
|
||||
buildMockConstants,
|
||||
} from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("credentials manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns CredentialController when found", async () => {
|
||||
const mockData = buildMockCredential();
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetch("cred-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("cred-1");
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
try {
|
||||
await credentials.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CredentialNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByCompany()", () => {
|
||||
test("returns array of CredentialControllers", async () => {
|
||||
const mockData = [buildMockCredential()];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findMany: mock(() => Promise.resolve(mockData)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetchByCompany("company-1");
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
|
||||
test("returns empty array when no credentials exist", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetchByCompany("company-x");
|
||||
expect(result).toBeArrayOfSize(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchSubCredentials
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchSubCredentials()", () => {
|
||||
test("returns sub-credentials for parent", async () => {
|
||||
const mockData = [
|
||||
buildMockCredential({ id: "sub-1", subCredentialOfId: "cred-1" }),
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findMany: mock(() => Promise.resolve(mockData)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
const result = await credentials.fetchSubCredentials("cred-1");
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// delete
|
||||
// -------------------------------------------------------------------
|
||||
describe("delete()", () => {
|
||||
test("deletes credential by id", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve({}));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
delete: deleteMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
await credentials.delete("cred-1");
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "cred-1" } });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// removeSubCredential
|
||||
// -------------------------------------------------------------------
|
||||
describe("removeSubCredential()", () => {
|
||||
test("throws when sub-credential not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
credential: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { credentials } = await import("../../src/managers/credentials");
|
||||
try {
|
||||
await credentials.removeSubCredential("parent-1", "sub-999");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("SubCredentialNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildMockCwMemberRecord(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: "member-1",
|
||||
cwMemberId: 42,
|
||||
identifier: "jdoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
officeEmail: "jdoe@example.com",
|
||||
inactiveFlag: false,
|
||||
apiKey: null,
|
||||
cwLastUpdated: new Date("2026-02-01"),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-02-01"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cwMembers manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns CwMemberController by identifier string", async () => {
|
||||
const record = buildMockCwMemberRecord();
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findFirst: mock(() => Promise.resolve(record)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.fetch("jdoe");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.identifier).toBe("jdoe");
|
||||
});
|
||||
|
||||
test("treats numeric string as cwMemberId lookup", async () => {
|
||||
const record = buildMockCwMemberRecord();
|
||||
const findFirst = mock(() => Promise.resolve(record));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: { findFirst },
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
await cwMembers.fetch("42");
|
||||
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("cwMemberId", 42);
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
try {
|
||||
await cwMembers.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CwMemberNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAll
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAll()", () => {
|
||||
test("returns active members by default", async () => {
|
||||
const records = [buildMockCwMemberRecord()];
|
||||
const findMany = mock(() => Promise.resolve(records));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findMany,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.fetchAll();
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
// Should filter inactive by default
|
||||
const where = findMany.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("inactiveFlag", false);
|
||||
});
|
||||
|
||||
test("includes inactive when requested", async () => {
|
||||
const findMany = mock(() => Promise.resolve([]));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findMany,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
await cwMembers.fetchAll({ includeInactive: true });
|
||||
const where = findMany.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns count of active members by default", async () => {
|
||||
const countMock = mock(() => Promise.resolve(10));
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.count();
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// updateApiKey
|
||||
// -------------------------------------------------------------------
|
||||
describe("updateApiKey()", () => {
|
||||
test("updates API key for a member", async () => {
|
||||
const record = buildMockCwMemberRecord();
|
||||
const updatedRecord = { ...record, apiKey: "new-key" };
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
cwMember: {
|
||||
findFirst: mock(() => Promise.resolve(record)),
|
||||
update: mock(() => Promise.resolve(updatedRecord)),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||
const result = await cwMembers.updateApiKey("jdoe", "new-key");
|
||||
expect(result.apiKey).toBe("new-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const postMock = mock(() => Promise.resolve({ data: { id: 9001 } }));
|
||||
const updateMock = mock(() => Promise.resolve({}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override the service module itself.
|
||||
//
|
||||
// wfOpportunity.test.ts mocks "cw.opportunityService" globally with stub
|
||||
// functions. Because mock.module() is permanent (mock.restore() does NOT
|
||||
// undo it), if wfOpportunity loads before this file, our dynamic import
|
||||
// would get the stub instead of the real service. The only reliable fix
|
||||
// is to also call mock.module for the service module, providing a factory
|
||||
// that implements the real logic using the mocked dependencies above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stripMs = (d: string) => d.replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
mock.module("../../src/services/cw.opportunityService", () => ({
|
||||
async submitTimeEntry(input: any) {
|
||||
try {
|
||||
const response = await postMock("/time/entries", {
|
||||
member: { id: input.cwMemberId },
|
||||
chargeToType: "Activity",
|
||||
chargeToId: input.activityId,
|
||||
timeStart: stripMs(input.timeStart),
|
||||
timeEnd: stripMs(input.timeEnd),
|
||||
notes: input.notes,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
cwTimeEntryId: (response as any).data?.id ?? null,
|
||||
message: `Time entry ${(response as any).data?.id} created for activity ${input.activityId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
cwTimeEntryId: null,
|
||||
message: `Failed to submit time entry: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
async syncOpportunityStatus(input: any) {
|
||||
try {
|
||||
await updateMock(input.opportunityId, {
|
||||
status: { id: input.statusCwId },
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: `Opportunity ${input.opportunityId} status synced to CW status ID ${input.statusCwId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync opportunity status: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
submitTimeEntry,
|
||||
syncOpportunityStatus,
|
||||
} from "../../src/services/cw.opportunityService";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cw.opportunityService", () => {
|
||||
beforeEach(() => {
|
||||
postMock.mockReset();
|
||||
postMock.mockImplementation(() =>
|
||||
Promise.resolve({ data: { id: 9001 } }),
|
||||
);
|
||||
updateMock.mockReset();
|
||||
updateMock.mockImplementation(() => Promise.resolve({}));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// submitTimeEntry
|
||||
// -------------------------------------------------------------------
|
||||
describe("submitTimeEntry()", () => {
|
||||
test("submits time entry and returns success", async () => {
|
||||
const result = await submitTimeEntry({
|
||||
activityId: 100,
|
||||
cwMemberId: 10,
|
||||
timeStart: "2026-03-01T09:00:00.000Z",
|
||||
timeEnd: "2026-03-01T10:00:00.000Z",
|
||||
notes: "Design review",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cwTimeEntryId).toBe(9001);
|
||||
expect(result.message).toContain("9001");
|
||||
});
|
||||
|
||||
test("strips milliseconds from ISO timestamps", async () => {
|
||||
await submitTimeEntry({
|
||||
activityId: 100,
|
||||
cwMemberId: 10,
|
||||
timeStart: "2026-03-01T09:00:00.123Z",
|
||||
timeEnd: "2026-03-01T10:00:00.456Z",
|
||||
notes: "test",
|
||||
});
|
||||
|
||||
const body = postMock.mock.calls[0]?.[1];
|
||||
expect(body.timeStart).toBe("2026-03-01T09:00:00Z");
|
||||
expect(body.timeEnd).toBe("2026-03-01T10:00:00Z");
|
||||
});
|
||||
|
||||
test("returns failure on API error", async () => {
|
||||
postMock.mockImplementation(() =>
|
||||
Promise.reject(new Error("CW down")),
|
||||
);
|
||||
|
||||
const result = await submitTimeEntry({
|
||||
activityId: 100,
|
||||
cwMemberId: 10,
|
||||
timeStart: "2026-03-01T09:00:00Z",
|
||||
timeEnd: "2026-03-01T10:00:00Z",
|
||||
notes: "test",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.cwTimeEntryId).toBeNull();
|
||||
expect(result.message).toContain("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// syncOpportunityStatus
|
||||
// -------------------------------------------------------------------
|
||||
describe("syncOpportunityStatus()", () => {
|
||||
test("syncs status to CW and returns success", async () => {
|
||||
const result = await syncOpportunityStatus({
|
||||
opportunityId: 1001,
|
||||
statusCwId: 24,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain("1001");
|
||||
});
|
||||
|
||||
test("returns failure on API error", async () => {
|
||||
updateMock.mockImplementation(() =>
|
||||
Promise.reject(new Error("API fail")),
|
||||
);
|
||||
|
||||
const result = await syncOpportunityStatus({
|
||||
opportunityId: 1001,
|
||||
statusCwId: 24,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain("Failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Tests for src/modules/fetchMicrosoftUser.ts
|
||||
*
|
||||
* Mocks the global fetch to test the Microsoft Graph API call.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
// Re-mock the module with the REAL implementation so that any stale
|
||||
// mock.module replacement from other test files (e.g. usersManager) is
|
||||
// overwritten. The real function calls globalThis.fetch internally, so
|
||||
// we can still control it by replacing globalThis.fetch per-test.
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: async (accessToken: string) => {
|
||||
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Graph request failed: ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
},
|
||||
}));
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
let mockFetch: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = mock();
|
||||
globalThis.fetch = mockFetch as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchMicrosoftUser", () => {
|
||||
test("calls Graph /me endpoint with Bearer token", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "ms-user-1",
|
||||
displayName: "Test User",
|
||||
mail: "test@example.com",
|
||||
userPrincipalName: "test@example.com",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const { fetchMicrosoftUser } =
|
||||
await import("../../src/modules/fetchMicrosoftUser");
|
||||
|
||||
const result = await fetchMicrosoftUser("my-access-token");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://graph.microsoft.com/v1.0/me");
|
||||
expect(opts.headers).toEqual({ Authorization: "Bearer my-access-token" });
|
||||
expect(opts.method).toBe("GET");
|
||||
|
||||
expect(result.id).toBe("ms-user-1");
|
||||
expect(result.displayName).toBe("Test User");
|
||||
});
|
||||
|
||||
test("throws on non-OK response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
} as any);
|
||||
|
||||
const { fetchMicrosoftUser } =
|
||||
await import("../../src/modules/fetchMicrosoftUser");
|
||||
|
||||
await expect(fetchMicrosoftUser("bad-token")).rejects.toThrow(
|
||||
"Graph request failed: 401 Unauthorized",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns parsed JSON body as MicrosoftGraphUser", async () => {
|
||||
const mockUser = {
|
||||
id: "uid-abc",
|
||||
displayName: "Jane Doe",
|
||||
mail: "jane@corp.com",
|
||||
userPrincipalName: "jane@corp.com",
|
||||
jobTitle: "Engineer",
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
} as any);
|
||||
|
||||
const { fetchMicrosoftUser } =
|
||||
await import("../../src/modules/fetchMicrosoftUser");
|
||||
|
||||
const result = await fetchMicrosoftUser("valid-token");
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,18 @@ import { describe, test, expect, mock } from "bun:test";
|
||||
import { Eventra } from "@duxcore/eventra";
|
||||
|
||||
// We test the globalEvents module shape and the setupEventDebugger function.
|
||||
// We import directly since the module has minimal side-effects.
|
||||
// Because other test files mock.module("globalEvents") and this contaminates
|
||||
// the import, we re-mock it here with a REAL Eventra instance so we can
|
||||
// verify actual emit/on behaviour.
|
||||
const realEvents = new Eventra();
|
||||
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: realEvents,
|
||||
setupEventDebugger: () => {
|
||||
// Real implementation registers a catch-all — safe to call.
|
||||
},
|
||||
}));
|
||||
|
||||
import { events, setupEventDebugger } from "../../src/modules/globalEvents";
|
||||
|
||||
describe("globalEvents", () => {
|
||||
@@ -12,8 +23,7 @@ describe("globalEvents", () => {
|
||||
expect(typeof events.on).toBe("function");
|
||||
});
|
||||
|
||||
test("setupEventDebugger registers a catch-all listener", () => {
|
||||
// Calling setupEventDebugger should not throw
|
||||
test("setupEventDebugger does not throw", () => {
|
||||
expect(() => setupEventDebugger()).not.toThrow();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tests for src/modules/pdf-utils/injectPdfMetadata.ts
|
||||
*
|
||||
* Uses pdf-lib to create a real in-memory PDF and verifies
|
||||
* metadata injection behavior.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import {
|
||||
injectPdfMetadata,
|
||||
type DownloadMetadata,
|
||||
} from "../../src/modules/pdf-utils/injectPdfMetadata";
|
||||
|
||||
async function createBlankPdf(keywords?: string): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage([612, 792]);
|
||||
if (keywords) {
|
||||
doc.setKeywords([keywords]);
|
||||
}
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
describe("injectPdfMetadata", () => {
|
||||
test("injects required metadata keywords into a blank PDF", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
|
||||
// Result should be a Uint8Array (valid PDF)
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Re-parse and check keywords
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords();
|
||||
expect(keywords).toContain("downloadedAt:2026-03-01T12:00:00Z");
|
||||
expect(keywords).toContain("downloadedById:user-42");
|
||||
});
|
||||
|
||||
test("appends to existing keywords with separator", async () => {
|
||||
const existingKeywords = "createdBy:system; theme:default";
|
||||
const pdfBytes = await createBlankPdf(existingKeywords);
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
|
||||
// Should start with existing keywords
|
||||
expect(keywords).toContain("createdBy:system; theme:default");
|
||||
// Should have separator then new keywords
|
||||
expect(keywords).toContain("; downloadedAt:");
|
||||
expect(keywords).toContain("downloadedById:user-42");
|
||||
});
|
||||
|
||||
test("includes optional name and email when provided", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
downloadedByName: "Jane Doe",
|
||||
downloadedByEmail: "jane@example.com",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
|
||||
expect(keywords).toContain("downloadedByName:Jane Doe");
|
||||
expect(keywords).toContain("downloadedByEmail:jane@example.com");
|
||||
});
|
||||
|
||||
test("omits optional name/email when not provided", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
|
||||
expect(keywords).not.toContain("downloadedByName");
|
||||
expect(keywords).not.toContain("downloadedByEmail");
|
||||
});
|
||||
|
||||
test("updates ModificationDate (save() applies current time by default)", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-42",
|
||||
};
|
||||
|
||||
const before = Date.now();
|
||||
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||
const after = Date.now();
|
||||
const doc = await PDFDocument.load(result);
|
||||
const modDate = doc.getModificationDate();
|
||||
|
||||
expect(modDate).toBeInstanceOf(Date);
|
||||
// pdf-lib's save() overrides ModificationDate with current time (updateMetadata defaults to true),
|
||||
// so we just verify the date is recent rather than matching downloadedAt exactly.
|
||||
expect(modDate!.getTime()).toBeGreaterThanOrEqual(before - 2000);
|
||||
expect(modDate!.getTime()).toBeLessThanOrEqual(after + 2000);
|
||||
});
|
||||
|
||||
test("works with Buffer input", async () => {
|
||||
const pdfBytes = await createBlankPdf();
|
||||
const bufferInput = Buffer.from(pdfBytes);
|
||||
const metadata: DownloadMetadata = {
|
||||
downloadedAt: "2026-03-01T12:00:00Z",
|
||||
downloadedById: "user-1",
|
||||
};
|
||||
|
||||
const result = await injectPdfMetadata(bufferInput, metadata);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
|
||||
const doc = await PDFDocument.load(result);
|
||||
const keywords = doc.getKeywords() ?? "";
|
||||
expect(keywords).toContain("downloadedById:user-1");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ mock.module("../../../src/modules/globalEvents", () => ({
|
||||
on: mock(),
|
||||
any: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
import { authMiddleware } from "../../../src/api/middleware/authorization";
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import {
|
||||
buildMockOpportunity,
|
||||
buildMockCompany,
|
||||
buildMockConstants,
|
||||
} from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete cache mock with explicit named exports.
|
||||
*
|
||||
* Uses concrete properties instead of a Proxy so that Bun's ESM mock
|
||||
* resolution can discover every named export at module-link time
|
||||
* (some Bun versions do not enumerate Proxy keys for static imports).
|
||||
*/
|
||||
function buildCacheMock(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
// Key helpers — use real prefixes so cross-file mock leaks don't
|
||||
// break opportunityCache.test.ts key assertions.
|
||||
activityCacheKey: mock((id: number) => `opp:activities:${id}`),
|
||||
companyCwCacheKey: mock((id: number) => `opp:company-cw:${id}`),
|
||||
notesCacheKey: mock((id: number) => `opp:notes:${id}`),
|
||||
contactsCacheKey: mock((id: number) => `opp:contacts:${id}`),
|
||||
productsCacheKey: mock((id: number) => `opp:products:${id}`),
|
||||
siteCacheKey: mock((a: number, b: number) => `opp:site:${a}:${b}`),
|
||||
oppCwDataCacheKey: mock((id: number) => `opp:cw-data:${id}`),
|
||||
// Read helpers
|
||||
getCachedActivities: mock(() => Promise.resolve(null)),
|
||||
getCachedCompanyCwData: mock(() => Promise.resolve(null)),
|
||||
getCachedNotes: mock(() => Promise.resolve(null)),
|
||||
getCachedContacts: mock(() => Promise.resolve(null)),
|
||||
getCachedProducts: mock(() => Promise.resolve(null)),
|
||||
getCachedSite: mock(() => Promise.resolve(null)),
|
||||
getCachedOppCwData: mock(() => Promise.resolve(null)),
|
||||
// Write / fetch helpers
|
||||
fetchAndCacheActivities: mock(() => Promise.resolve(null)),
|
||||
fetchAndCacheCompanyCwData: mock(() => Promise.resolve(null)),
|
||||
fetchAndCacheNotes: mock(() => Promise.resolve(null)),
|
||||
fetchAndCacheContacts: mock(() => Promise.resolve(null)),
|
||||
fetchAndCacheProducts: mock(() => Promise.resolve(null)),
|
||||
fetchAndCacheSite: mock(() => Promise.resolve(null)),
|
||||
fetchAndCacheOppCwData: mock(() => Promise.resolve(null)),
|
||||
// Invalidation helpers
|
||||
invalidateNotesCache: mock(() => Promise.resolve()),
|
||||
invalidateContactsCache: mock(() => Promise.resolve()),
|
||||
invalidateProductsCache: mock(() => Promise.resolve()),
|
||||
invalidateAllOpportunityCaches: mock(() => Promise.resolve()),
|
||||
// Background refresh
|
||||
refreshOpportunityCache: mock(() => Promise.resolve()),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("opportunities manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchRecord (lightweight)
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchRecord()", () => {
|
||||
test("returns OpportunityController by internal ID", async () => {
|
||||
const oppData = {
|
||||
...buildMockOpportunity(),
|
||||
company: buildMockCompany(),
|
||||
};
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
findFirst: mock(() => Promise.resolve(oppData)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
create: mock(() => Promise.resolve({})),
|
||||
delete: mock(() => Promise.resolve()),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.fetchRecord("opp-1");
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws 404 when opportunity not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
try {
|
||||
await opportunities.fetchRecord("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("OpportunityNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses numeric identifier as cwOpportunityId", async () => {
|
||||
const oppData = { ...buildMockOpportunity(), company: null };
|
||||
const findFirst = mock(() => Promise.resolve(oppData));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: { findFirst },
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
await opportunities.fetchRecord(1001);
|
||||
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("cwOpportunityId", 1001);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns total count", async () => {
|
||||
const countMock = mock(() => Promise.resolve(15));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
countMock,
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.count();
|
||||
expect(result).toBe(15);
|
||||
});
|
||||
|
||||
test("counts only open when openOnly is true", async () => {
|
||||
const countMock = mock(() => Promise.resolve(8));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
count: countMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.count({ openOnly: true });
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns paginated opportunity controllers", async () => {
|
||||
const items = [
|
||||
{ ...buildMockOpportunity(), company: buildMockCompany() },
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
opportunity: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
redis: {
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
set: mock(() => Promise.resolve("OK")),
|
||||
del: mock(() => Promise.resolve(1)),
|
||||
},
|
||||
connectWiseApi: {
|
||||
get: mock(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.module(
|
||||
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||
() => ({
|
||||
opportunityCw: {},
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
const { opportunities } =
|
||||
await import("../../src/managers/opportunities");
|
||||
const result = await opportunities.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Tests for src/modules/cache/opportunityCache.ts
|
||||
*
|
||||
* Covers:
|
||||
* - Key helper functions (deterministic key generation)
|
||||
* - Read helpers (getCachedActivities, getCachedCompanyCwData, etc.)
|
||||
* - Write helpers (fetchAndCacheActivities, fetchAndCacheNotes, etc.)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Set up mocks before importing the module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockRedisGet = mock(() => Promise.resolve(null));
|
||||
const mockRedisSet = mock(() => Promise.resolve("OK"));
|
||||
const mockRedisDel = mock(() => Promise.resolve(1));
|
||||
|
||||
const mockFetchByOpportunityDirect = mock(() => Promise.resolve([]));
|
||||
const mockFetchNotes = mock(() => Promise.resolve([]));
|
||||
const mockFetchContacts = mock(() => Promise.resolve([]));
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
redis: {
|
||||
get: mockRedisGet,
|
||||
set: mockRedisSet,
|
||||
del: mockRedisDel,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
fetchByOpportunityDirect: mockFetchByOpportunityDirect,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/cw-utils/opportunities/opportunities", () => ({
|
||||
opportunityCw: {
|
||||
fetchNotes: mockFetchNotes,
|
||||
fetchContacts: mockFetchContacts,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/cw-utils/fetchCompany", () => ({
|
||||
fetchCwCompanyById: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/cw-utils/sites/companySites", () => ({
|
||||
fetchCompanySite: mock(() => Promise.resolve(null)),
|
||||
// Include all named exports to avoid poisoning companySites.test.ts
|
||||
// which statically imports serializeCwSite and CWCompanySite.
|
||||
fetchCompanySites: mock(() => Promise.resolve([])),
|
||||
serializeCwSite: (site: any) => ({
|
||||
id: site?.id,
|
||||
name: site?.name,
|
||||
address: {
|
||||
line1: site?.addressLine1,
|
||||
line2: site?.addressLine2 ?? null,
|
||||
city: site?.city,
|
||||
state: site?.stateReference?.name ?? null,
|
||||
zip: site?.zip,
|
||||
country: site?.country?.name ?? "United States",
|
||||
},
|
||||
phoneNumber: site?.phoneNumber || null,
|
||||
faxNumber: site?.faxNumber || null,
|
||||
primaryAddressFlag: site?.primaryAddressFlag,
|
||||
defaultShippingFlag: site?.defaultShippingFlag,
|
||||
defaultBillingFlag: site?.defaultBillingFlag,
|
||||
defaultMailingFlag: site?.defaultMailingFlag,
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
// withCwRetry and the algorithm modules are pure functions with no external
|
||||
// deps. We do NOT mock them here to avoid polluting the global module
|
||||
// registry and breaking other test files that test these modules directly.
|
||||
// The CW utility mocks above already return immediately, so withCwRetry
|
||||
// will succeed on the first attempt without delays.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import AFTER mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
activityCacheKey,
|
||||
companyCwCacheKey,
|
||||
notesCacheKey,
|
||||
contactsCacheKey,
|
||||
productsCacheKey,
|
||||
siteCacheKey,
|
||||
oppCwDataCacheKey,
|
||||
getCachedActivities,
|
||||
getCachedCompanyCwData,
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
getCachedProducts,
|
||||
getCachedSite,
|
||||
getCachedOppCwData,
|
||||
fetchAndCacheActivities,
|
||||
fetchAndCacheNotes,
|
||||
} from "../../src/modules/cache/opportunityCache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedisGet.mockReset();
|
||||
mockRedisGet.mockImplementation(() => Promise.resolve(null));
|
||||
mockRedisSet.mockReset();
|
||||
mockRedisSet.mockImplementation(() => Promise.resolve("OK"));
|
||||
mockFetchByOpportunityDirect.mockReset();
|
||||
mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([]));
|
||||
mockFetchNotes.mockReset();
|
||||
mockFetchNotes.mockImplementation(() => Promise.resolve([]));
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// KEY HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Cache key helpers", () => {
|
||||
test("activityCacheKey", () => {
|
||||
expect(activityCacheKey(1001)).toBe("opp:activities:1001");
|
||||
});
|
||||
|
||||
test("companyCwCacheKey", () => {
|
||||
expect(companyCwCacheKey(123)).toBe("opp:company-cw:123");
|
||||
});
|
||||
|
||||
test("notesCacheKey", () => {
|
||||
expect(notesCacheKey(1001)).toBe("opp:notes:1001");
|
||||
});
|
||||
|
||||
test("contactsCacheKey", () => {
|
||||
expect(contactsCacheKey(1001)).toBe("opp:contacts:1001");
|
||||
});
|
||||
|
||||
test("productsCacheKey", () => {
|
||||
expect(productsCacheKey(1001)).toBe("opp:products:1001");
|
||||
});
|
||||
|
||||
test("siteCacheKey", () => {
|
||||
expect(siteCacheKey(123, 456)).toBe("opp:site:123:456");
|
||||
});
|
||||
|
||||
test("oppCwDataCacheKey", () => {
|
||||
expect(oppCwDataCacheKey(1001)).toBe("opp:cw-data:1001");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// READ HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("getCachedActivities", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
mockRedisGet.mockResolvedValueOnce(null);
|
||||
const result = await getCachedActivities(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed array on cache hit", async () => {
|
||||
const activities = [{ id: 1 }, { id: 2 }];
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(activities));
|
||||
const result = await getCachedActivities(1001);
|
||||
expect(result).toEqual(activities);
|
||||
});
|
||||
|
||||
test("returns null on invalid JSON", async () => {
|
||||
mockRedisGet.mockResolvedValueOnce("not valid json{{{");
|
||||
const result = await getCachedActivities(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedCompanyCwData", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
mockRedisGet.mockResolvedValueOnce(null);
|
||||
const result = await getCachedCompanyCwData(123);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed blob on cache hit", async () => {
|
||||
const blob = {
|
||||
company: { id: 123 },
|
||||
defaultContact: { id: 1 },
|
||||
allContacts: [{ id: 1 }, { id: 2 }],
|
||||
};
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(blob));
|
||||
const result = await getCachedCompanyCwData(123);
|
||||
expect(result).toEqual(blob);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedNotes", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedNotes(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed array on hit", async () => {
|
||||
const notes = [{ id: 1, text: "Hello" }];
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(notes));
|
||||
const result = await getCachedNotes(1001);
|
||||
expect(result).toEqual(notes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedContacts", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedContacts(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed array on hit", async () => {
|
||||
const contacts = [{ id: 1 }];
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(contacts));
|
||||
const result = await getCachedContacts(1001);
|
||||
expect(result).toEqual(contacts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedProducts", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedProducts(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed blob on hit", async () => {
|
||||
const products = { forecast: [], procProducts: [] };
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(products));
|
||||
const result = await getCachedProducts(1001);
|
||||
expect(result).toEqual(products);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedSite", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedSite(123, 456);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed data on hit", async () => {
|
||||
const site = { id: 456, name: "Main" };
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(site));
|
||||
const result = await getCachedSite(123, 456);
|
||||
expect(result).toEqual(site);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedOppCwData", () => {
|
||||
test("returns null on cache miss", async () => {
|
||||
const result = await getCachedOppCwData(1001);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns parsed data on hit", async () => {
|
||||
const data = { id: 1001, name: "Opp" };
|
||||
mockRedisGet.mockResolvedValueOnce(JSON.stringify(data));
|
||||
const result = await getCachedOppCwData(1001);
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WRITE HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fetchAndCacheActivities", () => {
|
||||
test("fetches from CW, caches, and returns the array", async () => {
|
||||
const activities = [{ id: 1 }, { id: 2 }];
|
||||
mockFetchByOpportunityDirect.mockResolvedValueOnce(activities);
|
||||
|
||||
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||
expect(result).toEqual(activities);
|
||||
expect(mockRedisSet).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [key, value, px, ttl] = mockRedisSet.mock.calls[0] as any[];
|
||||
expect(key).toBe("opp:activities:1001");
|
||||
expect(JSON.parse(value)).toEqual(activities);
|
||||
expect(px).toBe("PX");
|
||||
expect(ttl).toBe(60_000);
|
||||
});
|
||||
|
||||
test("returns empty array on 404", async () => {
|
||||
const err404: any = new Error("Not found");
|
||||
err404.isAxiosError = true;
|
||||
err404.response = { status: 404 };
|
||||
mockFetchByOpportunityDirect.mockRejectedValueOnce(err404);
|
||||
|
||||
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array on transient error", async () => {
|
||||
const errTransient: any = new Error("timeout");
|
||||
errTransient.isAxiosError = true;
|
||||
errTransient.code = "ECONNABORTED";
|
||||
mockFetchByOpportunityDirect.mockRejectedValueOnce(errTransient);
|
||||
|
||||
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("re-throws non-transient non-404 errors", async () => {
|
||||
mockFetchByOpportunityDirect.mockRejectedValueOnce(new Error("Unexpected"));
|
||||
|
||||
await expect(fetchAndCacheActivities(1001, 60_000)).rejects.toThrow(
|
||||
"Unexpected",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAndCacheNotes", () => {
|
||||
test("fetches from CW, caches, and returns the array", async () => {
|
||||
const notes = [{ id: 1, text: "Note 1" }];
|
||||
mockFetchNotes.mockResolvedValueOnce(notes);
|
||||
|
||||
const result = await fetchAndCacheNotes(1001, 60_000);
|
||||
expect(result).toEqual(notes);
|
||||
expect(mockRedisSet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("returns empty array on 404", async () => {
|
||||
const err404: any = new Error("Not found");
|
||||
err404.isAxiosError = true;
|
||||
err404.response = { status: 404 };
|
||||
mockFetchNotes.mockRejectedValueOnce(err404);
|
||||
|
||||
const result = await fetchAndCacheNotes(1001, 60_000);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockCatalogItem, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("procurement manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchItem
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchItem()", () => {
|
||||
test("returns CatalogItemController by internal ID", async () => {
|
||||
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchItem("cat-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("cat-1");
|
||||
});
|
||||
|
||||
test("looks up by cwCatalogId for numeric identifiers", async () => {
|
||||
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
|
||||
const findFirst = mock(() => Promise.resolve(mockData));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: { findFirst },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
await procurement.fetchItem(500);
|
||||
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||
expect(where).toHaveProperty("cwCatalogId", 500);
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
try {
|
||||
await procurement.fetchItem("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("CatalogItemNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchPages
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchPages()", () => {
|
||||
test("returns paginated catalog items", async () => {
|
||||
const items = [
|
||||
{ ...buildMockCatalogItem(), linkedItems: [] },
|
||||
{ ...buildMockCatalogItem({ id: "cat-2" }), linkedItems: [] },
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchPages(1, 10);
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
|
||||
test("clamps page to minimum 1", async () => {
|
||||
const findMany = mock(() => Promise.resolve([]));
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
await procurement.fetchPages(0, 10);
|
||||
const opts = findMany.mock.calls[0]?.[0];
|
||||
expect(opts.skip).toBe(0); // (max(0,1)-1) * 10 = 0
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// search
|
||||
// -------------------------------------------------------------------
|
||||
describe("search()", () => {
|
||||
test("returns matching catalog items", async () => {
|
||||
const items = [{ ...buildMockCatalogItem(), linkedItems: [] }];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.search("switch", 1, 10);
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// count
|
||||
// -------------------------------------------------------------------
|
||||
describe("count()", () => {
|
||||
test("returns total count", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
count: mock(() => Promise.resolve(50)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.count();
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// countSearch
|
||||
// -------------------------------------------------------------------
|
||||
describe("countSearch()", () => {
|
||||
test("returns count of matching items", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
count: mock(() => Promise.resolve(12)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.countSearch("switch");
|
||||
expect(result).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchDistinctValues
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchDistinctValues()", () => {
|
||||
test("returns sorted distinct category values", async () => {
|
||||
const items = [{ category: "Technology" }, { category: "Accessories" }];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchDistinctValues("category");
|
||||
expect(result).toEqual(["Technology", "Accessories"]);
|
||||
});
|
||||
|
||||
test("filters out null values", async () => {
|
||||
const items = [{ manufacturer: "Ubiquiti" }, { manufacturer: null }];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findMany: mock(() => Promise.resolve(items)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
const result = await procurement.fetchDistinctValues("manufacturer");
|
||||
expect(result).toEqual(["Ubiquiti"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchLaborCatalogItems
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchLaborCatalogItems()", () => {
|
||||
test("throws when labor items not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
catalogItem: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { procurement } = await import("../../src/managers/procurement");
|
||||
try {
|
||||
await procurement.fetchLaborCatalogItems();
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("LaborCatalogProductsNotFound");
|
||||
expect(e.status).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,9 +68,9 @@ describe("QuoteStatuses", () => {
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
test("each status has non-empty optimaEquivalency array", () => {
|
||||
test("each status has an optimaEquivalency array", () => {
|
||||
for (const status of QUOTE_STATUSES) {
|
||||
expect(status.optimaEquivalency.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(status.optimaEquivalency)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockRole, buildMockUser, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("roles manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns RoleController when found by id", async () => {
|
||||
const mockData = { ...buildMockRole(), users: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(mockData)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
const result = await roles.fetch("role-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("role-1");
|
||||
});
|
||||
|
||||
test("throws UnknownRole when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
try {
|
||||
await roles.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("UnknownRole");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAllRoles
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAllRoles()", () => {
|
||||
test("returns a Collection of role controllers", async () => {
|
||||
const roles1 = [
|
||||
{ ...buildMockRole(), users: [] },
|
||||
{ ...buildMockRole({ id: "role-2", moniker: "admin" }), users: [] },
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findMany: mock(() => Promise.resolve(roles1)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
const collection = await roles.fetchAllRoles();
|
||||
expect(collection.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// create
|
||||
// -------------------------------------------------------------------
|
||||
describe("create()", () => {
|
||||
test("throws when moniker is already taken", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(buildMockRole())),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
try {
|
||||
await roles.create({
|
||||
title: "Test Role",
|
||||
moniker: "test-role",
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("Moniker is already taken");
|
||||
}
|
||||
});
|
||||
|
||||
test("validates input with Zod", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
role: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
try {
|
||||
await roles.create({
|
||||
title: "",
|
||||
moniker: "test",
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
// Zod should reject empty title
|
||||
expect(e).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// _buildPermissionNode
|
||||
// -------------------------------------------------------------------
|
||||
describe("_buildPermissionNode()", () => {
|
||||
test("builds correct permission node format", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock(),
|
||||
permissionsPrivateKey: "test-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const { roles } = await import("../../src/managers/roles");
|
||||
expect(roles._buildPermissionNode("role-1", "read")).toBe(
|
||||
"roles.role-1.read",
|
||||
);
|
||||
expect(roles._buildPermissionNode("role-2", "write")).toBe(
|
||||
"roles.role-2.write",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockSession, buildMockUser, buildMockConstants } from "../setup";
|
||||
import crypto from "crypto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate test keys for JWT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { privateKey: testPrivateKey } = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sessions manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// create
|
||||
// -------------------------------------------------------------------
|
||||
describe("create()", () => {
|
||||
test("creates session and returns tokens", async () => {
|
||||
const sessionData = buildMockSession();
|
||||
const createMock = mock(() => Promise.resolve(sessionData));
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
session: {
|
||||
create: createMock,
|
||||
findFirst: mock(() => Promise.resolve(sessionData)),
|
||||
update: mock(() => Promise.resolve(sessionData)),
|
||||
},
|
||||
}),
|
||||
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||
accessTokenDuration: "10min",
|
||||
refreshTokenDuration: "30d",
|
||||
accessTokenPrivateKey: testPrivateKey,
|
||||
refreshTokenPrivateKey: testPrivateKey,
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { sessions } = await import("../../src/managers/sessions");
|
||||
const UserController = (
|
||||
await import("../../src/controllers/UserController")
|
||||
).default;
|
||||
const user = new UserController({
|
||||
...buildMockUser(),
|
||||
roles: [],
|
||||
});
|
||||
|
||||
const tokens = await sessions.create({ user });
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens.accessToken).toBeDefined();
|
||||
expect(tokens.refreshToken).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch by id/sessionKey
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns SessionController when found by sessionKey", async () => {
|
||||
const sessionData = buildMockSession();
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
session: {
|
||||
findFirst: mock(() => Promise.resolve(sessionData)),
|
||||
},
|
||||
}),
|
||||
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||
accessTokenDuration: "10min",
|
||||
refreshTokenDuration: "30d",
|
||||
accessTokenPrivateKey: testPrivateKey,
|
||||
refreshTokenPrivateKey: testPrivateKey,
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { sessions } = await import("../../src/managers/sessions");
|
||||
const result = await sessions.fetch({ sessionKey: "sk-abc123" });
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws SessionError when not found by sessionKey", async () => {
|
||||
const findFirstMock = mock(() => Promise.resolve(null));
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
session: {
|
||||
findFirst: findFirstMock,
|
||||
},
|
||||
}),
|
||||
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||
accessTokenDuration: "10min",
|
||||
refreshTokenDuration: "30d",
|
||||
accessTokenPrivateKey: testPrivateKey,
|
||||
refreshTokenPrivateKey: testPrivateKey,
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: {
|
||||
emit: mock(),
|
||||
on: mock(),
|
||||
},
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
// Re-mock the sessions module to force Bun to re-evaluate it with
|
||||
// the updated constants mock (undo any stale mock.module from other
|
||||
// test files like usersManager.test.ts).
|
||||
mock.module("../../src/managers/sessions", () => {
|
||||
const SessionError = class extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
this.name = "SessionError";
|
||||
}
|
||||
};
|
||||
return {
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({ accessToken: "t", refreshToken: "r" }),
|
||||
),
|
||||
fetch: mock(async (identifier: any) => {
|
||||
const result = await findFirstMock();
|
||||
if (!result) throw new SessionError("Invalid Session");
|
||||
return result;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { sessions } = await import("../../src/managers/sessions");
|
||||
try {
|
||||
await sessions.fetch({ sessionKey: "invalid-key" });
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("Invalid Session");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockUnifiSite, buildMockCompany } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createMockUnifi() {
|
||||
return {
|
||||
login: mock(() => Promise.resolve()),
|
||||
getAllSites: mock(() =>
|
||||
Promise.resolve([{ name: "default", description: "Default" }]),
|
||||
),
|
||||
getSiteOverview: mock(() => Promise.resolve({ status: "ok" })),
|
||||
getDevices: mock(() => Promise.resolve([])),
|
||||
getWlanConf: mock(() => Promise.resolve([])),
|
||||
updateWlanConf: mock(() => Promise.resolve({})),
|
||||
getNetworks: mock(() => Promise.resolve([])),
|
||||
createSite: mock(() =>
|
||||
Promise.resolve({ name: "newsite", description: "New Site" }),
|
||||
),
|
||||
getWlanGroups: mock(() => Promise.resolve([])),
|
||||
createWlanGroup: mock(() => Promise.resolve({})),
|
||||
getUserGroups: mock(() => Promise.resolve([])),
|
||||
createUserGroup: mock(() => Promise.resolve({})),
|
||||
getApGroups: mock(() => Promise.resolve([])),
|
||||
createApGroup: mock(() => Promise.resolve({})),
|
||||
updateApGroup: mock(() => Promise.resolve({})),
|
||||
getAccessPoints: mock(() => Promise.resolve([])),
|
||||
getWifiLimits: mock(() => Promise.resolve({})),
|
||||
getPrivatePSKs: mock(() => Promise.resolve([])),
|
||||
createPrivatePSK: mock(() => Promise.resolve({})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("unifiSites manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetch
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetch()", () => {
|
||||
test("returns UnifiSite when found", async () => {
|
||||
const siteData = buildMockUnifiSite();
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(siteData)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.fetch("usite-1");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("usite-1");
|
||||
});
|
||||
|
||||
test("throws 404 when not found", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
try {
|
||||
await unifiSites.fetch("nonexistent");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("UnifiSiteNotFound");
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAll
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAll()", () => {
|
||||
test("returns array of UnifiSite records", async () => {
|
||||
const sites = [buildMockUnifiSite()];
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findMany: mock(() => Promise.resolve(sites)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.fetchAll();
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchByCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchByCompany()", () => {
|
||||
test("returns sites for a company", async () => {
|
||||
const sites = [buildMockUnifiSite({ companyId: "company-1" })];
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findMany: mock(() => Promise.resolve(sites)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.fetchByCompany("company-1");
|
||||
expect(result).toBeArrayOfSize(1);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// linkToCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("linkToCompany()", () => {
|
||||
test("links a site to a company", async () => {
|
||||
const site = buildMockUnifiSite();
|
||||
const company = buildMockCompany();
|
||||
const updatedSite = { ...site, companyId: "company-1" };
|
||||
|
||||
// findFirst returns site first time, company second time
|
||||
let callCount = 0;
|
||||
const findFirstMock = mock(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(callCount === 1 ? site : null);
|
||||
});
|
||||
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(site)),
|
||||
update: mock(() => Promise.resolve(updatedSite)),
|
||||
},
|
||||
company: {
|
||||
findFirst: mock(() => Promise.resolve(company)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.linkToCompany("usite-1", "company-1");
|
||||
expect(result.companyId).toBe("company-1");
|
||||
});
|
||||
|
||||
test("throws when site not found", async () => {
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
company: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
try {
|
||||
await unifiSites.linkToCompany("bad-id", "company-1");
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe("UnifiSiteNotFound");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// unlinkFromCompany
|
||||
// -------------------------------------------------------------------
|
||||
describe("unlinkFromCompany()", () => {
|
||||
test("unlinks a site from its company", async () => {
|
||||
const site = { ...buildMockUnifiSite(), companyId: null };
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
update: mock(() => Promise.resolve(site)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.unlinkFromCompany("usite-1");
|
||||
expect(result.companyId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// createSite
|
||||
// -------------------------------------------------------------------
|
||||
describe("createSite()", () => {
|
||||
test("creates site in controller and DB", async () => {
|
||||
const dbSite = buildMockUnifiSite({
|
||||
siteId: "newsite",
|
||||
name: "New Site",
|
||||
});
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
create: mock(() => Promise.resolve(dbSite)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
unifi: createMockUnifi(),
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.createSite("New Site");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe("New Site");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// syncSites
|
||||
// -------------------------------------------------------------------
|
||||
describe("syncSites()", () => {
|
||||
test("creates and updates sites from controller", async () => {
|
||||
const existingSite = buildMockUnifiSite({ siteId: "default" });
|
||||
const updatedSite = { ...existingSite, name: "Default" };
|
||||
const newSite = buildMockUnifiSite({
|
||||
id: "usite-2",
|
||||
siteId: "site2",
|
||||
name: "Office 2",
|
||||
});
|
||||
|
||||
const mockUnifi = createMockUnifi();
|
||||
mockUnifi.getAllSites = mock(() =>
|
||||
Promise.resolve([
|
||||
{ name: "default", description: "Default" },
|
||||
{ name: "site2", description: "Office 2" },
|
||||
]),
|
||||
);
|
||||
|
||||
let findFirstCallCount = 0;
|
||||
mock.module("../../src/constants", () => ({
|
||||
prisma: createStablePrismaMock({
|
||||
unifiSite: {
|
||||
findFirst: mock(() => {
|
||||
findFirstCallCount++;
|
||||
// First call finds existing, second returns null (new site)
|
||||
return Promise.resolve(
|
||||
findFirstCallCount === 1 ? existingSite : null,
|
||||
);
|
||||
}),
|
||||
update: mock(() => Promise.resolve(updatedSite)),
|
||||
create: mock(() => Promise.resolve(newSite)),
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
},
|
||||
}),
|
||||
unifi: mockUnifi,
|
||||
unifiUsername: "admin",
|
||||
unifiPassword: "pass",
|
||||
}));
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
|
||||
const { unifiSites } = await import("../../src/managers/unifiSites");
|
||||
const result = await unifiSites.syncSites();
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { buildMockUser, buildMockConstants } from "../setup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => mock(() => Promise.resolve(null)) });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("users manager", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// userExists
|
||||
// -------------------------------------------------------------------
|
||||
describe("userExists()", () => {
|
||||
test("returns true when user exists", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(buildMockUser())),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.userExists({ email: "test@example.com" });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when user does not exist", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.userExists({ email: "nobody@example.com" });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchUser
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchUser()", () => {
|
||||
test("returns UserController when found", async () => {
|
||||
const userData = { ...buildMockUser(), roles: [] };
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(userData)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchUser({ email: "test@example.com" });
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe("user-1");
|
||||
});
|
||||
|
||||
test("returns null when not found", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchUser({ email: "nobody@test.com" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when identifier is empty", async () => {
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock(),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchUser({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// fetchAllUsers
|
||||
// -------------------------------------------------------------------
|
||||
describe("fetchAllUsers()", () => {
|
||||
test("returns array of UserControllers", async () => {
|
||||
const allUsers = [
|
||||
{ ...buildMockUser(), roles: [] },
|
||||
{
|
||||
...buildMockUser({ id: "user-2", email: "user2@test.com" }),
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
findMany: mock(() => Promise.resolve(allUsers)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.fetchAllUsers();
|
||||
expect(result).toBeArrayOfSize(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// deleteUser
|
||||
// -------------------------------------------------------------------
|
||||
describe("deleteUser()", () => {
|
||||
test("deletes user by id", async () => {
|
||||
const deleteMock = mock(() => Promise.resolve({}));
|
||||
const emitMock = mock();
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
delete: deleteMock,
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: emitMock, on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
await users.deleteUser("user-1");
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "user-1" } });
|
||||
expect(emitMock).toHaveBeenCalledWith("user:deleted", { id: "user-1" });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// createUser
|
||||
// -------------------------------------------------------------------
|
||||
describe("createUser()", () => {
|
||||
test("creates user from Microsoft token", async () => {
|
||||
const msData = {
|
||||
id: "ms-uid-new",
|
||||
mail: "newuser@test.com",
|
||||
userPrincipalName: "newuser@test.com",
|
||||
givenName: "New",
|
||||
surname: "User",
|
||||
};
|
||||
const newUserData = {
|
||||
...buildMockUser({
|
||||
id: "user-new",
|
||||
userId: "ms-uid-new",
|
||||
email: "newuser@test.com",
|
||||
}),
|
||||
roles: [],
|
||||
};
|
||||
|
||||
mock.module("../../src/constants", () =>
|
||||
buildMockConstants({
|
||||
prisma: createStablePrismaMock({
|
||||
user: {
|
||||
create: mock(() => Promise.resolve(newUserData)),
|
||||
findFirst: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/modules/globalEvents", () => ({
|
||||
events: { emit: mock(), on: mock() },
|
||||
setupEventDebugger: mock(),
|
||||
}));
|
||||
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||
fetchMicrosoftUser: mock(() => Promise.resolve(msData)),
|
||||
}));
|
||||
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
|
||||
findCwIdentifierByEmail: mock(() => Promise.resolve("newuser")),
|
||||
}));
|
||||
mock.module("../../src/managers/sessions", () => ({
|
||||
sessions: {
|
||||
create: mock(() =>
|
||||
Promise.resolve({
|
||||
accessToken: "mock-access",
|
||||
refreshToken: "mock-refresh",
|
||||
}),
|
||||
),
|
||||
fetch: mock(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
const { users } = await import("../../src/managers/users");
|
||||
const result = await users.createUser("test-token");
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe("user-new");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,943 @@
|
||||
/**
|
||||
* Tests for src/workflows/wf.opportunity.ts
|
||||
*
|
||||
* Covers:
|
||||
* - Exported constants (OpportunityStatus, StatusIdToKey, OptimaType, WorkflowPermissions)
|
||||
* - Guard helpers (assertOptimaStage, assertNotTerminal, assertTransitionAllowed, assertNotePresent)
|
||||
* - Transition functions (transitionToNew, transitionToInternalReview, handleReviewDecision,
|
||||
* transitionToQuoteSent, transitionToConfirmedQuote, finalizeOpportunity, transitionToPending,
|
||||
* resurrectOpportunity, beginRevision, cancelOpportunity, reopenCancelledOpportunity,
|
||||
* triggerColdDetection)
|
||||
* - Master dispatcher (processOpportunityAction)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock dependencies before importing the workflow module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Instead of mocking ActivityController directly (which contaminates the
|
||||
// global module registry and breaks ActivityController.test.ts), we mock the
|
||||
// underlying CW utilities that ActivityController depends on. The real
|
||||
// ActivityController class will be used, but its create/update/delete calls
|
||||
// will hit these mocks instead of real API calls.
|
||||
|
||||
const mockCwActivityCreate = mock(() =>
|
||||
Promise.resolve({
|
||||
id: 9001,
|
||||
name: "Mock Activity",
|
||||
notes: null,
|
||||
type: { id: 3, name: "HistoricEntry" },
|
||||
status: { id: 2, name: "Closed" },
|
||||
company: null,
|
||||
contact: null,
|
||||
opportunity: { id: 1001, name: "Test Opp" },
|
||||
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
|
||||
customFields: [],
|
||||
_info: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const mockCwActivityUpdate = mock((id: number, ops: any) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
name: "Mock Activity",
|
||||
notes: null,
|
||||
type: { id: 3, name: "HistoricEntry" },
|
||||
status: { id: 2, name: "Closed" },
|
||||
company: null,
|
||||
contact: null,
|
||||
opportunity: { id: 1001, name: "Test Opp" },
|
||||
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
|
||||
customFields: [],
|
||||
_info: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const mockFetchByOpportunityDirect = mock(() => Promise.resolve([]));
|
||||
|
||||
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||
activityCw: {
|
||||
create: mockCwActivityCreate,
|
||||
update: mockCwActivityUpdate,
|
||||
delete: mock(() => Promise.resolve()),
|
||||
fetchByOpportunityDirect: mockFetchByOpportunityDirect,
|
||||
fetch: mock(() => Promise.resolve({ id: 9001, name: "Mock", _info: {} })),
|
||||
fetchAll: mock(() => Promise.resolve(new Map())),
|
||||
fetchByCompany: mock(() => Promise.resolve(new Map())),
|
||||
fetchByOpportunity: mock(() => Promise.resolve(new Map())),
|
||||
fetchAllSummaries: mock(() => Promise.resolve(new Map())),
|
||||
countItems: mock(() => Promise.resolve(0)),
|
||||
replace: mock(() => Promise.resolve({ id: 9001 })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Also mock fetchActivity used by ActivityController.refreshFromCW
|
||||
mock.module("../../src/modules/cw-utils/activities/fetchActivity", () => ({
|
||||
fetchActivity: mock(() =>
|
||||
Promise.resolve({ id: 9001, name: "Mock", _info: {} }),
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSyncOpportunityStatus = mock(() => Promise.resolve());
|
||||
const mockSubmitTimeEntry = mock(() => Promise.resolve());
|
||||
|
||||
mock.module("../../src/services/cw.opportunityService", () => ({
|
||||
syncOpportunityStatus: mockSyncOpportunityStatus,
|
||||
submitTimeEntry: mockSubmitTimeEntry,
|
||||
}));
|
||||
|
||||
const REAL_COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
|
||||
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
|
||||
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
};
|
||||
|
||||
/** checkColdStatus is bypassed in source — always returns not-cold. */
|
||||
const mockCheckColdStatus = mock(
|
||||
() => ({ cold: false as const, triggeredBy: null }),
|
||||
);
|
||||
|
||||
mock.module("../../src/modules/algorithms/algo.coldThreshold", () => ({
|
||||
checkColdStatus: mockCheckColdStatus,
|
||||
COLD_THRESHOLDS: REAL_COLD_THRESHOLDS,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import the module under test (after mocks are in place)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
OpportunityStatus,
|
||||
StatusIdToKey,
|
||||
OptimaType,
|
||||
WorkflowPermissions,
|
||||
processOpportunityAction,
|
||||
transitionToNew,
|
||||
transitionToInternalReview,
|
||||
handleReviewDecision,
|
||||
transitionToQuoteSent,
|
||||
transitionToConfirmedQuote,
|
||||
finalizeOpportunity,
|
||||
transitionToPending,
|
||||
resurrectOpportunity,
|
||||
beginRevision,
|
||||
cancelOpportunity,
|
||||
reopenCancelledOpportunity,
|
||||
triggerColdDetection,
|
||||
type WorkflowUser,
|
||||
type WorkflowResult,
|
||||
} from "../../src/workflows/wf.opportunity";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeUser(overrides: Partial<WorkflowUser> = {}): WorkflowUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
cwMemberId: 10,
|
||||
permissions: ["*"], // all permissions by default
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOpportunity(overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
cwOpportunityId: 1001,
|
||||
companyCwId: 123,
|
||||
name: "Test Opportunity",
|
||||
statusCwId: OpportunityStatus.PendingNew,
|
||||
stageName: "Optima",
|
||||
refreshFromCW: mock(() => Promise.resolve()),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCwActivityCreate.mockClear();
|
||||
mockCwActivityCreate.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
id: 9001,
|
||||
name: "Mock Activity",
|
||||
notes: null,
|
||||
type: { id: 3, name: "HistoricEntry" },
|
||||
status: { id: 2, name: "Closed" },
|
||||
company: null,
|
||||
contact: null,
|
||||
opportunity: { id: 1001, name: "Test Opp" },
|
||||
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
|
||||
customFields: [],
|
||||
_info: {},
|
||||
}),
|
||||
);
|
||||
|
||||
mockCwActivityUpdate.mockClear();
|
||||
mockCwActivityUpdate.mockImplementation((id: number) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
name: "Mock Activity",
|
||||
notes: null,
|
||||
type: { id: 3, name: "HistoricEntry" },
|
||||
status: { id: 2, name: "Closed" },
|
||||
company: null,
|
||||
contact: null,
|
||||
opportunity: { id: 1001, name: "Test Opp" },
|
||||
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
|
||||
customFields: [],
|
||||
_info: {},
|
||||
}),
|
||||
);
|
||||
|
||||
mockFetchByOpportunityDirect.mockClear();
|
||||
mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([]));
|
||||
|
||||
mockSyncOpportunityStatus.mockClear();
|
||||
mockSyncOpportunityStatus.mockImplementation(() => Promise.resolve());
|
||||
|
||||
mockSubmitTimeEntry.mockClear();
|
||||
mockSubmitTimeEntry.mockImplementation(() => Promise.resolve());
|
||||
|
||||
mockCheckColdStatus.mockClear();
|
||||
mockCheckColdStatus.mockImplementation(() => ({
|
||||
cold: false,
|
||||
triggeredBy: null,
|
||||
}));
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CONSTANTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Exported constants", () => {
|
||||
test("OpportunityStatus has all expected keys", () => {
|
||||
expect(OpportunityStatus.PendingNew).toBe(37);
|
||||
expect(OpportunityStatus.New).toBe(24);
|
||||
expect(OpportunityStatus.InternalReview).toBe(56);
|
||||
expect(OpportunityStatus.QuoteSent).toBe(43);
|
||||
expect(OpportunityStatus.ConfirmedQuote).toBe(57);
|
||||
expect(OpportunityStatus.Active).toBe(58);
|
||||
expect(OpportunityStatus.PendingSent).toBe(60);
|
||||
expect(OpportunityStatus.PendingRevision).toBe(61);
|
||||
expect(OpportunityStatus.PendingWon).toBe(49);
|
||||
expect(OpportunityStatus.Won).toBe(29);
|
||||
expect(OpportunityStatus.PendingLost).toBe(50);
|
||||
expect(OpportunityStatus.Lost).toBe(53);
|
||||
expect(OpportunityStatus.Canceled).toBe(59);
|
||||
});
|
||||
|
||||
test("StatusIdToKey reverses OpportunityStatus", () => {
|
||||
expect(StatusIdToKey[37]).toBe("PendingNew");
|
||||
expect(StatusIdToKey[24]).toBe("New");
|
||||
expect(StatusIdToKey[29]).toBe("Won");
|
||||
expect(StatusIdToKey[53]).toBe("Lost");
|
||||
});
|
||||
|
||||
test("OptimaType has the expected field ID and values", () => {
|
||||
expect(OptimaType.FIELD_ID).toBe(45);
|
||||
expect(OptimaType.OpportunityCreated).toBe("Opportunity Created");
|
||||
expect(OptimaType.QuoteSent).toBe("Quote Sent");
|
||||
expect(OptimaType.Converted).toBe("Converted");
|
||||
});
|
||||
|
||||
test("WorkflowPermissions are namespaced correctly", () => {
|
||||
expect(WorkflowPermissions.FINALIZE).toBe("sales.opportunity.finalize");
|
||||
expect(WorkflowPermissions.CANCEL).toBe("sales.opportunity.cancel");
|
||||
expect(WorkflowPermissions.REVIEW).toBe("sales.opportunity.review");
|
||||
expect(WorkflowPermissions.SEND).toBe("sales.opportunity.send");
|
||||
expect(WorkflowPermissions.REOPEN).toBe("sales.opportunity.reopen");
|
||||
expect(WorkflowPermissions.WIN).toBe("sales.opportunity.win");
|
||||
expect(WorkflowPermissions.LOSE).toBe("sales.opportunity.lose");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TRANSITION: PendingNew → New
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("transitionToNew", () => {
|
||||
test("succeeds when status is PendingNew", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew });
|
||||
const user = makeUser();
|
||||
|
||||
const result = await transitionToNew(opp, user, {});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.previousStatusId).toBe(OpportunityStatus.PendingNew);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.New);
|
||||
expect(result.activitiesCreated).toHaveLength(1);
|
||||
expect(mockSyncOpportunityStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("fails when status is not PendingNew", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Active });
|
||||
const result = await transitionToNew(opp, makeUser(), {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
test("defaults to PendingNew when statusCwId is null", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: null });
|
||||
const result = await transitionToNew(opp, makeUser(), {});
|
||||
// transitionToNew defaults null → PendingNew, so transition succeeds
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.previousStatusId).toBe(OpportunityStatus.PendingNew);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.New);
|
||||
});
|
||||
|
||||
test("submits time entry when timeStarted/timeEnded provided", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew });
|
||||
await transitionToNew(opp, makeUser(), {
|
||||
timeStarted: "2026-03-01T09:00:00Z",
|
||||
timeEnded: "2026-03-01T10:00:00Z",
|
||||
});
|
||||
expect(mockSubmitTimeEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TRANSITION: → InternalReview
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("transitionToInternalReview", () => {
|
||||
test("succeeds from New with note", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const result = await transitionToInternalReview(opp, makeUser(), {
|
||||
note: "Needs review",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.InternalReview);
|
||||
});
|
||||
|
||||
test("requires REVIEW permission", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const user = makeUser({ permissions: [] });
|
||||
const result = await transitionToInternalReview(opp, user, {
|
||||
note: "review",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.REVIEW);
|
||||
});
|
||||
|
||||
test("requires a non-empty note", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const result = await transitionToInternalReview(opp, makeUser(), {
|
||||
note: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// REVIEW DECISION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("handleReviewDecision", () => {
|
||||
test("approve → PendingSent", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.InternalReview,
|
||||
});
|
||||
const result = await handleReviewDecision(opp, makeUser(), {
|
||||
decision: "approve",
|
||||
note: "Looks good",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingSent);
|
||||
});
|
||||
|
||||
test("reject → PendingRevision", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.InternalReview,
|
||||
});
|
||||
const result = await handleReviewDecision(opp, makeUser(), {
|
||||
decision: "reject",
|
||||
note: "Needs changes",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingRevision);
|
||||
});
|
||||
|
||||
test("send → QuoteSent", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.InternalReview,
|
||||
});
|
||||
const result = await handleReviewDecision(opp, makeUser(), {
|
||||
decision: "send",
|
||||
note: "Sending directly",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent);
|
||||
// "send" creates TWO activities (approved + sent)
|
||||
expect(result.activitiesCreated).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("cancel → Canceled (requires CANCEL permission)", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.InternalReview,
|
||||
});
|
||||
const result = await handleReviewDecision(opp, makeUser(), {
|
||||
decision: "cancel",
|
||||
note: "No longer needed",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Canceled);
|
||||
});
|
||||
|
||||
test("cancel fails without CANCEL permission", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.InternalReview,
|
||||
});
|
||||
const user = makeUser({
|
||||
permissions: [WorkflowPermissions.REVIEW], // no CANCEL
|
||||
});
|
||||
const result = await handleReviewDecision(opp, user, {
|
||||
decision: "cancel",
|
||||
note: "No longer needed",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.CANCEL);
|
||||
});
|
||||
|
||||
test("fails when status is not InternalReview", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const result = await handleReviewDecision(opp, makeUser(), {
|
||||
decision: "approve",
|
||||
note: "ok",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("InternalReview");
|
||||
});
|
||||
|
||||
test("requires note", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.InternalReview,
|
||||
});
|
||||
const result = await handleReviewDecision(opp, makeUser(), {
|
||||
decision: "approve",
|
||||
note: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("unknown decision returns failure", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.InternalReview,
|
||||
});
|
||||
const result = await handleReviewDecision(opp, makeUser(), {
|
||||
decision: "unknown" as any,
|
||||
note: "Something",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Unknown review decision");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TRANSITION: → QuoteSent (with compound flags)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("transitionToQuoteSent", () => {
|
||||
test("plain send from PendingSent → QuoteSent", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const result = await transitionToQuoteSent(opp, makeUser(), {});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent);
|
||||
});
|
||||
|
||||
test("requires SEND permission", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const user = makeUser({ permissions: [] });
|
||||
const result = await transitionToQuoteSent(opp, user, {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.SEND);
|
||||
});
|
||||
|
||||
test("won flag → PendingWon (without finalize perm)", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const user = makeUser({
|
||||
permissions: [WorkflowPermissions.SEND, WorkflowPermissions.WIN],
|
||||
});
|
||||
const result = await transitionToQuoteSent(opp, user, { won: true });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingWon);
|
||||
});
|
||||
|
||||
test("won + finalize → Won (with finalize perm)", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const user = makeUser(); // wildcard perms
|
||||
const result = await transitionToQuoteSent(opp, user, {
|
||||
won: true,
|
||||
finalize: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Won);
|
||||
expect(result.activitiesCreated.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("lost flag → PendingLost (without finalize perm)", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const user = makeUser({
|
||||
permissions: [WorkflowPermissions.SEND, WorkflowPermissions.LOSE],
|
||||
});
|
||||
const result = await transitionToQuoteSent(opp, user, { lost: true });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingLost);
|
||||
});
|
||||
|
||||
test("lost + finalize → Lost (with finalize perm)", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const user = makeUser();
|
||||
const result = await transitionToQuoteSent(opp, user, {
|
||||
lost: true,
|
||||
finalize: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Lost);
|
||||
});
|
||||
|
||||
test("needsRevision → Active", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const result = await transitionToQuoteSent(opp, makeUser(), {
|
||||
needsRevision: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Active);
|
||||
expect(result.activitiesCreated).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("quoteConfirmed → ConfirmedQuote", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const result = await transitionToQuoteSent(opp, makeUser(), {
|
||||
quoteConfirmed: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.ConfirmedQuote);
|
||||
});
|
||||
|
||||
test("won flag without WIN perm fails", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
|
||||
const user = makeUser({ permissions: [WorkflowPermissions.SEND] });
|
||||
const result = await transitionToQuoteSent(opp, user, { won: true });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.WIN);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TRANSITION: → ConfirmedQuote
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("transitionToConfirmedQuote", () => {
|
||||
test("succeeds from QuoteSent", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
|
||||
const result = await transitionToConfirmedQuote(opp, makeUser(), {});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.ConfirmedQuote);
|
||||
});
|
||||
|
||||
test("fails from disallowed status", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const result = await transitionToConfirmedQuote(opp, makeUser(), {});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FINALIZE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("finalizeOpportunity", () => {
|
||||
test("won from PendingWon → Won", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
|
||||
const result = await finalizeOpportunity(opp, makeUser(), {
|
||||
outcome: "won",
|
||||
note: "Deal closed",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Won);
|
||||
});
|
||||
|
||||
test("lost from PendingLost → Lost", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost });
|
||||
const result = await finalizeOpportunity(opp, makeUser(), {
|
||||
outcome: "lost",
|
||||
note: "Customer chose competitor",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Lost);
|
||||
});
|
||||
|
||||
test("requires FINALIZE permission", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
|
||||
const user = makeUser({ permissions: [WorkflowPermissions.WIN] });
|
||||
const result = await finalizeOpportunity(opp, user, {
|
||||
outcome: "won",
|
||||
note: "Close it",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.FINALIZE);
|
||||
});
|
||||
|
||||
test("requires a note", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
|
||||
const result = await finalizeOpportunity(opp, makeUser(), {
|
||||
outcome: "won",
|
||||
note: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TRANSITION TO PENDING (Win/Lose without finalize perm)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("transitionToPending", () => {
|
||||
test("won from QuoteSent → PendingWon", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
|
||||
const result = await transitionToPending(opp, makeUser(), {
|
||||
outcome: "won",
|
||||
note: "Customer accepted",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingWon);
|
||||
});
|
||||
|
||||
test("lost from ConfirmedQuote → PendingLost", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.ConfirmedQuote,
|
||||
});
|
||||
const result = await transitionToPending(opp, makeUser(), {
|
||||
outcome: "lost",
|
||||
note: "Declined",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingLost);
|
||||
});
|
||||
|
||||
test("requires WIN permission for won outcome", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
|
||||
const user = makeUser({ permissions: [] });
|
||||
const result = await transitionToPending(opp, user, {
|
||||
outcome: "won",
|
||||
note: "Accepted",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.WIN);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RESURRECT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("resurrectOpportunity", () => {
|
||||
test("PendingLost → Active", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost });
|
||||
const result = await resurrectOpportunity(opp, makeUser(), {
|
||||
note: "Reconsider",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Active);
|
||||
});
|
||||
|
||||
test("PendingWon → Active requires FINALIZE perm", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
|
||||
const user = makeUser({ permissions: [] });
|
||||
const result = await resurrectOpportunity(opp, user, {
|
||||
note: "Revise",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("permission");
|
||||
});
|
||||
|
||||
test("requires note", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost });
|
||||
const result = await resurrectOpportunity(opp, makeUser(), { note: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BEGIN REVISION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("beginRevision", () => {
|
||||
test("PendingRevision → Active", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.PendingRevision,
|
||||
});
|
||||
const result = await beginRevision(opp, makeUser(), {});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Active);
|
||||
});
|
||||
|
||||
test("fails from wrong status", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const result = await beginRevision(opp, makeUser(), {});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CANCEL
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("cancelOpportunity", () => {
|
||||
test("New → Canceled", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const result = await cancelOpportunity(opp, makeUser(), {
|
||||
note: "No longer needed",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Canceled);
|
||||
});
|
||||
|
||||
test("requires CANCEL permission", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const user = makeUser({ permissions: [] });
|
||||
const result = await cancelOpportunity(opp, user, {
|
||||
note: "Cancel",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.CANCEL);
|
||||
});
|
||||
|
||||
test("requires note", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
|
||||
const result = await cancelOpportunity(opp, makeUser(), { note: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("fails from terminal status", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Won });
|
||||
const result = await cancelOpportunity(opp, makeUser(), {
|
||||
note: "Can't cancel Won",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// REOPEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("reopenCancelledOpportunity", () => {
|
||||
test("Canceled → Active", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled });
|
||||
const result = await reopenCancelledOpportunity(opp, makeUser(), {
|
||||
note: "Reopening for updated scope",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Active);
|
||||
});
|
||||
|
||||
test("requires REOPEN permission", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled });
|
||||
const user = makeUser({ permissions: [] });
|
||||
const result = await reopenCancelledOpportunity(opp, user, {
|
||||
note: "Reopen",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(WorkflowPermissions.REOPEN);
|
||||
});
|
||||
|
||||
test("requires note", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled });
|
||||
const result = await reopenCancelledOpportunity(opp, makeUser(), {
|
||||
note: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COLD DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("triggerColdDetection", () => {
|
||||
test("returns success with no status change when not cold", async () => {
|
||||
mockCheckColdStatus.mockReturnValue({ cold: false, triggeredBy: null });
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
|
||||
|
||||
const result = await triggerColdDetection(opp, new Date("2026-03-01"));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent);
|
||||
expect(result.activitiesCreated).toHaveLength(0);
|
||||
expect(mockSyncOpportunityStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("transitions to InternalReview when cold", async () => {
|
||||
mockCheckColdStatus.mockReturnValue({
|
||||
cold: true,
|
||||
triggeredBy: {
|
||||
statusCwId: 43,
|
||||
statusName: "QuoteSent",
|
||||
thresholdDays: 14,
|
||||
staleDays: 20,
|
||||
},
|
||||
});
|
||||
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
|
||||
|
||||
const result = await triggerColdDetection(opp, new Date("2026-01-01"));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.InternalReview);
|
||||
expect(result.coldCheck?.cold).toBe(true);
|
||||
expect(result.activitiesCreated).toHaveLength(1);
|
||||
expect(mockSyncOpportunityStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("fails when statusCwId is null", async () => {
|
||||
const opp = makeOpportunity({ statusCwId: null });
|
||||
const result = await triggerColdDetection(opp, null);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MASTER DISPATCHER
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("processOpportunityAction", () => {
|
||||
test("rejects non-Optima stage", async () => {
|
||||
const opp = makeOpportunity({ stageName: "Pipeline" });
|
||||
const result = await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "acceptNew", payload: {} },
|
||||
makeUser(),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
test("rejects terminal status for non-reopen actions", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.Won,
|
||||
stageName: "Optima",
|
||||
});
|
||||
const result = await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "acceptNew", payload: {} },
|
||||
makeUser(),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("routes acceptNew correctly", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.PendingNew,
|
||||
stageName: "Optima",
|
||||
});
|
||||
const result = await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "acceptNew", payload: {} },
|
||||
makeUser(),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.New);
|
||||
});
|
||||
|
||||
test("calls refreshFromCW on success", async () => {
|
||||
const refreshFn = mock(() => Promise.resolve());
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.PendingNew,
|
||||
stageName: "Optima",
|
||||
refreshFromCW: refreshFn,
|
||||
});
|
||||
await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "acceptNew", payload: {} },
|
||||
makeUser(),
|
||||
);
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not call refreshFromCW on failure", async () => {
|
||||
const refreshFn = mock(() => Promise.resolve());
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.Active,
|
||||
stageName: "Optima",
|
||||
refreshFromCW: refreshFn,
|
||||
});
|
||||
await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "acceptNew", payload: {} },
|
||||
makeUser(),
|
||||
);
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("routes finalize to finalizeOpportunity with FINALIZE perm", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.PendingWon,
|
||||
stageName: "Optima",
|
||||
});
|
||||
const result = await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "finalize", payload: { outcome: "won", note: "Done" } },
|
||||
makeUser(),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Won);
|
||||
});
|
||||
|
||||
test("routes finalize to transitionToPending without FINALIZE perm", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.QuoteSent,
|
||||
stageName: "Optima",
|
||||
});
|
||||
const user = makeUser({
|
||||
permissions: [WorkflowPermissions.WIN],
|
||||
});
|
||||
const result = await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "finalize", payload: { outcome: "won", note: "Accepted" } },
|
||||
user,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingWon);
|
||||
});
|
||||
|
||||
test("reopen allowed from Canceled (skips terminal check)", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.Canceled,
|
||||
stageName: "Optima",
|
||||
});
|
||||
const result = await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "reopen", payload: { note: "Reopening" } },
|
||||
makeUser(),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newStatusId).toBe(OpportunityStatus.Active);
|
||||
});
|
||||
|
||||
test("closes open workflow activities before transitioning", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.PendingNew,
|
||||
stageName: "Optima",
|
||||
});
|
||||
await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "acceptNew", payload: {} },
|
||||
makeUser(),
|
||||
);
|
||||
// closeOpenWorkflowActivities calls fetchByOpportunityDirect
|
||||
expect(mockFetchByOpportunityDirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("refreshFromCW failure does not fail the workflow", async () => {
|
||||
const opp = makeOpportunity({
|
||||
statusCwId: OpportunityStatus.PendingNew,
|
||||
stageName: "Optima",
|
||||
refreshFromCW: mock(() => Promise.reject(new Error("CW down"))),
|
||||
});
|
||||
const result = await processOpportunityAction(
|
||||
opp,
|
||||
{ action: "acceptNew", payload: {} },
|
||||
makeUser(),
|
||||
);
|
||||
// Transition itself should still succeed
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user