diff --git a/extensions/cxone/README.md b/extensions/cxone/README.md new file mode 100644 index 000000000..172f39783 --- /dev/null +++ b/extensions/cxone/README.md @@ -0,0 +1,155 @@ +# CXone Extension + +This Cognigy extension integrates with **CXone**, providing authenticated HTTP request capabilities for CXone API integration in your Cognigy flows. + +## Installation + +1. Build the extension: + ```bash + npm install + npm run build + ``` + +2. Upload the generated `cxone-*.tar.gz` file to your Cognigy.AI instance via **Manage > Extensions > Upload Extension** + +## CXone Authenticated Call Node + +Makes authenticated HTTP requests to any API using CXone bearer tokens. + +### Features +- Automatic token injection from `input.data.cxonetoken` or `context.cxonetoken` +- Multiple HTTP methods (GET, POST, PUT, PATCH, DELETE) +- JSON, Text, and Form data payload types +- Configurable timeout (1-20 seconds, default 8s) +- Automatic retry with exponential backoff +- Request/response logging with sensitive data redaction +- Response header storage option +- Structured error handling +- Configurable response storage (input or context, custom key) +- Optional insecure SSL support for development environments + +### Configuration + +#### Basic +- **URL** (`url`): Complete endpoint URL. +- **HTTP Method** (`method`): One of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. + +#### Headers +- **Headers** (`headers`): Additional request headers as JSON object (e.g. `{"X-Custom": "value"}`). + The `Authorization` header is injected automatically from the CXone token. +- **Store Response Headers** (`storeResponseHeaders`): When enabled, response headers are stored together with the body in the configured target. + +#### Payload +- **Payload Type** (`payloadType`): Selects how to send the request body for non-DELETE methods (including GET with body for complex queries): + - `json`: uses **Request Body (JSON)**. + - `text`: uses **Request Body (Text)**. + - `form`: uses **Request Body (Form Data)**. + + **Note**: While GET requests with body data are non-standard HTTP practice, some APIs require complex query parameters that are better suited to request bodies. +- **Request Body (JSON)** (`bodyJson`): JSON object body (e.g. `{"foo": "bar"}`). +- **Request Body (Text)** (`bodyText`): Raw text body. +- **Request Body (Form Data)** (`bodyForm`): JSON object treated as key/value pairs for form data. + +#### Execution +- **Timeout (ms)** (`timeoutMs`): Request timeout in milliseconds (1,000–20,000). There is still a hard 20s execution budget in Cognigy. +- **Enable Retry** (`enableRetry`): When enabled, the node automatically retries on network errors, timeouts and server errors. +- **Retry Attempts** (`retryAttempts`): Number of additional retries (1–5). `2` means `1 initial + 2 retries = 3` total attempts. Uses exponential backoff with jitter. + +#### Error handling & debug +- **Fail on Non-2xx Status** (`failOnNon2xx`): + - `true` (default): non-2xx responses are treated as errors and surfaced via structured error objects. + - `false`: non-2xx responses are treated as successful and returned with status and body. +- **Debug Mode** (`debugMode`): Enables detailed logging of request/response metadata. Sensitive values such as tokens and credentials are redacted. + +#### Security +- **Allow Insecure SSL** (`allowInsecureSSL`): + Allows calls to HTTPS endpoints with self-signed or otherwise untrusted certificates. + **Use only in development/testing**, never in production. + +#### Storage +- **Store Result In** (`responseTarget`): Where to store the response (`context` or `input`). Default is `context`. +- **Key to store Result** (`responseKey`): Path/key used under the selected target (default: `cxoneApiResponse`). + +Example: with default storage, a successful call will store the result in `context.cxoneApiResponse`. + +### Example usage + +```json +{ + "url": "https://api.cxone.example.com/v1/customers", + "method": "GET", + "headers": { + "X-Tenant": "my-tenant" + }, + "timeoutMs": 8000, + "enableRetry": true, + "retryAttempts": 2, + "responseTarget": "context", + "responseKey": "cxoneApiResponse", + "failOnNon2xx": true, + "debugMode": false +} +``` + +In the flow, you can then read the response from `context.cxoneApiResponse`. + +## Testing + +This extension uses **Jest** for unit tests. + +- **Run all tests**: `npm test` +- **Run tests in watch mode**: `npm run test:watch` + +The test suite covers: +- Node descriptors in `src/nodes` (`authenticated-call.ts`) including success and error paths +- Helpers in `src/helpers` (`errors.ts`) to verify error handling and response formatting +- Jest is configured with coverage thresholds targeting near-100% coverage for helper modules + +## Troubleshooting + +### Common Issues + +**Error: "Missing cxonetoken"** +- Ensure the flow is invoked by CXone with authentication token in `input.data.cxonetoken` or `context.cxonetoken` + +**HTTP Error responses** +- Check the endpoint URL is correct and accessible +- Verify the HTTP method matches the API requirements +- Ensure request headers are properly formatted JSON + +**Timeout errors** +- Increase the timeout value in node configuration (max 20 seconds) +- Check network connectivity to the target API + +**Retry exhaustion** +- Check server availability and response times +- Verify API endpoint is not rate-limiting requests + +## Development + +### Building + +```bash +npm install +npm run transpile +npm run lint +npm run build # Includes transpile, lint, and zip +``` + +### Project Structure + +``` +src/ +├── nodes/ # Node implementations (authenticated-call) +├── helpers/ # Utility functions (errors) +├── test-utils/ # Test utilities and mocks +└── types/ # TypeScript type definitions +``` + +## License + +NiCE + +## Author + +NiCE diff --git a/extensions/cxone/icon.png b/extensions/cxone/icon.png new file mode 100644 index 000000000..e752fef34 Binary files /dev/null and b/extensions/cxone/icon.png differ diff --git a/extensions/cxone/jest.config.cjs b/extensions/cxone/jest.config.cjs new file mode 100644 index 000000000..4dfdb5bd0 --- /dev/null +++ b/extensions/cxone/jest.config.cjs @@ -0,0 +1,22 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.spec.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + roots: ['/src'], + setupFilesAfterEnv: ['/src/test-utils/jest-setup.ts'], + collectCoverageFrom: [ + 'src/**/*.{ts,js}', + '!src/**/__tests__/**/*' + ], + coverageThreshold: { + global: { + branches: 80, + functions: 90, + lines: 90, + statements: 90 + } + } +}; + + diff --git a/extensions/cxone/package.json b/extensions/cxone/package.json new file mode 100644 index 000000000..1aab08e4e --- /dev/null +++ b/extensions/cxone/package.json @@ -0,0 +1,32 @@ +{ + "name": "cxone", + "version": "1.0.28", + "description": "CXone Extension for authenticated HTTP requests to CXone APIs", + "main": "build/module.js", + "scripts": { + "transpile": "tsc -p .", + "zip": "node zip.js", + "build": "npm run transpile && npm run lint && npm run zip", + "lint": "tslint -c tslint.json src/**/*.ts", + "test": "jest", + "test:watch": "jest --watch" + }, + "keywords": [ + "CXone", + "HTTP Client", + "Authenticated Requests", + "CXone API" + ], + "author": "NiCE", + "license": "NiCE", + "dependencies": { + "@cognigy/extension-tools": "^0.14.0" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tslint": "^6.1.3", + "typescript": "^5.9.2" + } +} \ No newline at end of file diff --git a/extensions/cxone/src/helpers/__tests__/errors.spec.ts b/extensions/cxone/src/helpers/__tests__/errors.spec.ts new file mode 100644 index 000000000..1ec05709b --- /dev/null +++ b/extensions/cxone/src/helpers/__tests__/errors.spec.ts @@ -0,0 +1,137 @@ +/// + +import { CXoneError, createErrorMessage } from "../errors"; + +describe("errors", () => { + describe("CXoneError", () => { + it("should set all properties correctly when instantiated", () => { + const error = new CXoneError("Component", "Action", "Message", { key: "value" }); + + expect(error.component).toBe("Component"); + expect(error.action).toBe("Action"); + expect(error.message).toBe("Component: Action: Message"); + expect(error.name).toBe("CXoneError"); + expect(error.context).toEqual({ key: "value" }); + }); + + it("should format error message correctly", () => { + const error = new CXoneError("TokenManager", "encryptToken", "Failed to encrypt"); + + expect(error.message).toBe("TokenManager: encryptToken: Failed to encrypt"); + }); + + it("should set name property to 'CXoneError'", () => { + const error = new CXoneError("Component", "Action", "Message"); + + expect(error.name).toBe("CXoneError"); + }); + + it("should work without optional context parameter", () => { + const error = new CXoneError("Component", "Action", "Message"); + + expect(error.component).toBe("Component"); + expect(error.action).toBe("Action"); + expect(error.message).toBe("Component: Action: Message"); + expect(error.context).toBeUndefined(); + }); + + it("should be an instance of Error", () => { + const error = new CXoneError("Component", "Action", "Message"); + + expect(error).toBeInstanceOf(Error); + }); + + it("should be an instance of CXoneError", () => { + const error = new CXoneError("Component", "Action", "Message"); + + expect(error).toBeInstanceOf(CXoneError); + }); + + it("should preserve stack trace when Error.captureStackTrace is available", () => { + const error = new CXoneError("Component", "Action", "Message"); + + // Stack trace should exist (at least in Node.js environment) + expect(error.stack).toBeDefined(); + expect(typeof error.stack).toBe("string"); + expect(error.stack!.length).toBeGreaterThan(0); + }); + + it("should handle empty strings in component, action, and message", () => { + const error = new CXoneError("", "", ""); + + expect(error.component).toBe(""); + expect(error.action).toBe(""); + expect(error.message).toBe(": : "); + }); + + it("should handle special characters in message", () => { + const error = new CXoneError("Component", "Action", "Error: with: colons"); + + expect(error.message).toBe("Component: Action: Error: with: colons"); + }); + + it("should handle context with various data types", () => { + const context = { + string: "value", + number: 123, + boolean: true, + null: null, + array: [1, 2, 3], + nested: { key: "value" } + }; + const error = new CXoneError("Component", "Action", "Message", context); + + expect(error.context).toEqual(context); + }); + }); + + describe("createErrorMessage", () => { + it("should format message correctly with component, action, and details", () => { + const message = createErrorMessage("Component", "Action", "Details"); + + expect(message).toBe("Component: Action: Details"); + }); + + it("should handle various component/action/details combinations", () => { + expect(createErrorMessage("TokenManager", "encryptToken", "Failed to encrypt")).toBe( + "TokenManager: encryptToken: Failed to encrypt" + ); + expect(createErrorMessage("ApiClient", "getToken", "Network error")).toBe( + "ApiClient: getToken: Network error" + ); + expect(createErrorMessage("Handler", "process", "Invalid input")).toBe( + "Handler: process: Invalid input" + ); + }); + + it("should handle empty strings", () => { + expect(createErrorMessage("", "", "")).toBe(": : "); + expect(createErrorMessage("Component", "", "Details")).toBe("Component: : Details"); + expect(createErrorMessage("", "Action", "Details")).toBe(": Action: Details"); + }); + + it("should handle special characters", () => { + expect(createErrorMessage("Comp:onent", "Act:ion", "Det:ails")).toBe( + "Comp:onent: Act:ion: Det:ails" + ); + expect(createErrorMessage("Component", "Action", "Error: with: colons")).toBe( + "Component: Action: Error: with: colons" + ); + }); + + it("should handle numbers as strings", () => { + expect(createErrorMessage("Component1", "Action2", "Details3")).toBe( + "Component1: Action2: Details3" + ); + }); + + it("should handle long strings", () => { + const longString = "a".repeat(1000); + const message = createErrorMessage("Component", "Action", longString); + + expect(message).toBe(`Component: Action: ${longString}`); + expect(message.length).toBe(1019); // Component: Action: + 1000 chars (19 + 1000) + }); + }); +}); + diff --git a/extensions/cxone/src/helpers/errors.ts b/extensions/cxone/src/helpers/errors.ts new file mode 100644 index 000000000..d6730bf40 --- /dev/null +++ b/extensions/cxone/src/helpers/errors.ts @@ -0,0 +1,157 @@ +/** + * Custom error classes for CXone Extension + */ + +import { StandardizedError, StandardizedResponse, ErrorType } from "../types"; + +/** + * Base CXone error class with context + */ +export class CXoneError extends Error { + public readonly component: string; + public readonly action: string; + public readonly context?: Record; + + constructor( + component: string, + action: string, + message: string, + context?: Record + ) { + super(`${component}: ${action}: ${message}`); + this.name = "CXoneError"; + this.component = component; + this.action = action; + this.context = context; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CXoneError); + } + } +} + +/** + * Create a standardized error message + */ +export function createErrorMessage( + component: string, + action: string, + details: string +): string { + return `${component}: ${action}: ${details}`; +} + +/** + * Generate a unique request ID for error tracking + */ +export function generateRequestId(): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `req_${timestamp}_${random}`; +} + +/** + * Create a standardized error object + */ +export function createStandardizedError( + type: ErrorType, + message: string, + status?: number, + details?: Record, + requestId?: string +): StandardizedError { + return { + type, + message, + ...(status !== undefined && { status }), + ...(details && { details }), + ...(requestId && { requestId }) + }; +} + +/** + * Create a standardized error response + */ +export function createErrorResponse( + type: ErrorType, + message: string, + status?: number, + details?: Record, + requestId?: string +): StandardizedResponse { + return { + success: false, + error: createStandardizedError(type, message, status, details, requestId) + }; +} + +/** + * Create a standardized success response + */ +export function createSuccessResponse(data: any): StandardizedResponse { + return { + success: true, + data + }; +} + +/** + * Predefined error creators for common scenarios + */ +export const ErrorCreators = { + missingToken: (requestId?: string): StandardizedResponse => + createErrorResponse( + "MissingToken", + "Missing cxonetoken in input.data or context.cxonetoken. This node can only be used in flows invoked by CXone with authentication.", + 401, + { tokenSources: ["input.data.cxonetoken", "context.cxonetoken"] }, + requestId + ), + + invalidConfig: (message: string, details?: Record, requestId?: string): StandardizedResponse => + createErrorResponse( + "InvalidConfig", + message, + 400, + details, + requestId + ), + + timeout: (timeoutMs: number, requestId?: string): StandardizedResponse => + createErrorResponse( + "Timeout", + `Request timeout after ${timeoutMs}ms`, + 408, + { timeoutMs, hardLimitMs: 20000, cognigyStandard: "20-second execution budget" }, + requestId + ), + + networkError: (message: string, requestId?: string): StandardizedResponse => + createErrorResponse( + "NetworkError", + `Network error: ${message}`, + 503, + { isRetryable: true }, + requestId + ), + + httpError: (status: number, message: string, body?: any, requestId?: string): StandardizedResponse => + createErrorResponse( + "HttpError", + `HTTP ${status}: ${message}`, + status, + { responseBody: body, isRetryable: status >= 500 || status === 429 }, + requestId + ), + + retryExhausted: (attempts: number, lastError: string, elapsedMs: number, requestId?: string): StandardizedResponse => + createErrorResponse( + "RetryExhausted", + `All ${attempts} attempts failed. Last error: ${lastError}`, + 500, + { attempts, elapsedMs, lastError }, + requestId + ) +}; + diff --git a/extensions/cxone/src/module.ts b/extensions/cxone/src/module.ts new file mode 100644 index 000000000..5f56e95ad --- /dev/null +++ b/extensions/cxone/src/module.ts @@ -0,0 +1,12 @@ +import { createExtension } from "@cognigy/extension-tools"; +import { cxoneAuthenticatedCall } from './nodes/authenticated-call'; + +export default createExtension({ + nodes: [ + cxoneAuthenticatedCall + ], + connections: [], + options: { + label: "CXone" + } +}); \ No newline at end of file diff --git a/extensions/cxone/src/nodes/__tests__/authenticated-call.spec.ts b/extensions/cxone/src/nodes/__tests__/authenticated-call.spec.ts new file mode 100644 index 000000000..c1f2d588a --- /dev/null +++ b/extensions/cxone/src/nodes/__tests__/authenticated-call.spec.ts @@ -0,0 +1,4242 @@ +/// + +import { cxoneAuthenticatedCall } from "../authenticated-call"; +import { createMockCognigy } from "../../test-utils/mockCognigyApi"; +import * as http from "http"; +import * as https from "https"; + +// Mock Node.js http and https modules +jest.mock("http"); +jest.mock("https"); + +const mockedHttp = http as jest.Mocked; +const mockedHttps = https as jest.Mocked; + +describe("cxoneAuthenticatedCall node", () => { + + const baseConfig = { + url: "https://api.example.com/test", + method: "GET" as const, + headers: { + "Custom-Header": "test-value" + }, + body: "" + }; + + // Helper function to setup successful HTTP mock + const setupHttpMock = (statusCode: number, responseData: any, headers: any = {}) => { + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(statusCode, responseData, headers); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + mockedHttp.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + return { mockRequest, mockResponse }; + }; + + // Helper function to setup HTTP error mock + const setupHttpErrorMock = (error: Error) => { + const mockRequest = createMockRequest(); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => mockRequest.emit('error', error), 0); + return mockRequest as any; + }); + + mockedHttp.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => mockRequest.emit('error', error), 0); + return mockRequest as any; + }); + + return { mockRequest }; + }; + + // Helper function to setup HTTP timeout mock + const setupHttpTimeoutMock = () => { + const mockRequest = createMockRequest(); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => mockRequest.emit('timeout'), 0); + return mockRequest as any; + }); + + mockedHttp.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => mockRequest.emit('timeout'), 0); + return mockRequest as any; + }); + + return { mockRequest }; + }; + + // Helper function to setup HTTP retry mock that always fails + const setupHttpRetryErrorMock = (error: Error, callCounter?: { count: number }) => { + const mockRequest = createMockRequest(); + + const implementation = (options: any, callback: any) => { + if (callCounter) callCounter.count++; + setTimeout(() => mockRequest.emit('error', error), 0); + return mockRequest as any; + }; + + mockedHttps.request.mockImplementation(implementation); + mockedHttp.request.mockImplementation(implementation); + + return { mockRequest }; + }; + + // Helper function to setup HTTP retry mock with timeout that always fails + const setupHttpRetryTimeoutMock = (callCounter?: { count: number }) => { + const mockRequest = createMockRequest(); + + const implementation = (options: any, callback: any) => { + if (callCounter) callCounter.count++; + setTimeout(() => mockRequest.emit('timeout'), 0); + return mockRequest as any; + }; + + mockedHttps.request.mockImplementation(implementation); + mockedHttp.request.mockImplementation(implementation); + + return { mockRequest }; + }; + + // Helper function to setup HTTP mock that captures options and returns success + const setupHttpOptionsCaptureMock = (statusCode: number, responseData: any, headers: any = {}, optionsCapture?: any) => { + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(statusCode, responseData, headers); + + const implementation = (options: any, callback: any) => { + // Capture options if provided + if (optionsCapture) { + Object.assign(optionsCapture, options); + } + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }; + + mockedHttps.request.mockImplementation(implementation); + mockedHttp.request.mockImplementation(implementation); + + return { mockRequest, mockResponse }; + }; + + // Helper function to setup HTTP mock that fails first then succeeds + const setupHttpFailThenSucceedMock = (error: Error, successStatusCode: number, successData: any, headers: any = {}, callCounter?: { count: number }) => { + const mockRequest = createMockRequest(); + + const implementation = (options: any, callback: any) => { + if (callCounter) callCounter.count++; + const attemptCount = callCounter ? callCounter.count : 1; + + if (attemptCount === 1) { + // First attempt fails + setTimeout(() => mockRequest.emit('error', error), 0); + } else { + // Subsequent attempts succeed + const mockResponse = createMockResponse(successStatusCode, { ...successData, attempt: attemptCount }, headers); + setTimeout(() => callback(mockResponse), 0); + } + return mockRequest as any; + }; + + mockedHttps.request.mockImplementation(implementation); + mockedHttp.request.mockImplementation(implementation); + + return { mockRequest }; + }; + + // Helper function to setup HTTP mock that returns error status first then succeeds + const setupHttpErrorStatusThenSucceedMock = ( + errorStatusCode: number, + errorData: any, + successStatusCode: number, + successData: any, + headers: any = {}, + callCounter?: { count: number } + ) => { + const mockRequest = createMockRequest(); + + const implementation = (options: any, callback: any) => { + if (callCounter) callCounter.count++; + const attemptCount = callCounter ? callCounter.count : 1; + + if (attemptCount === 1) { + // First attempt returns error status + const mockErrorResponse = createMockResponse(errorStatusCode, errorData, headers); + setTimeout(() => callback(mockErrorResponse), 0); + } else { + // Subsequent attempts succeed + const mockSuccessResponse = createMockResponse(successStatusCode, { ...successData, attempt: attemptCount }, headers); + setTimeout(() => callback(mockSuccessResponse), 0); + } + return mockRequest as any; + }; + + mockedHttps.request.mockImplementation(implementation); + mockedHttp.request.mockImplementation(implementation); + + return { mockRequest }; + }; + + // Mock request object + const createMockRequest = () => { + const mockRequest = { + write: jest.fn(), + end: jest.fn(), + on: jest.fn(), + destroy: jest.fn(), + emit: jest.fn(), + _events: {} as any + }; + + // Set up the on method to store event handlers + mockRequest.on.mockImplementation((event: string, handler: Function) => { + if (!mockRequest._events[event]) { + mockRequest._events[event] = []; + } + mockRequest._events[event].push(handler); + }); + + // Set up emit to call stored event handlers + mockRequest.emit.mockImplementation((event: string, ...args: any[]) => { + if (mockRequest._events[event]) { + mockRequest._events[event].forEach((handler: Function) => handler(...args)); + } + return true; + }); + + return mockRequest; + }; + + // Mock response object + const createMockResponse = (statusCode: number, data: any, headers: any = {}) => ({ + statusCode, + headers: { 'content-type': 'application/json', ...headers }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'data') { + setTimeout(() => callback(typeof data === 'string' ? data : JSON.stringify(data)), 0); + } else if (event === 'end') { + setTimeout(() => callback(), 0); + } + }) + }); + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default successful mock + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, { success: true }); + + mockedHttp.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + }); + + describe("successful requests", () => { + it("should make a GET request with cxonetoken from input.data", async () => { + const responseData = { success: true, data: "test" }; + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, responseData); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token-123" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token-123" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + expect(cognigy.api.output).toHaveBeenCalledWith( + "Request completed successfully", + { + success: true, + data: { + status: 200, + body: { success: true, data: "test" } + } + } + ); + }); + + it("should make a POST request with JSON body", async () => { + const postConfig = { + ...baseConfig, + method: "POST" as const, + body: { name: "test", value: 123 } + }; + + const responseData = { id: 456 }; + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(201, responseData); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token-456" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: postConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "POST", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token-456" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + expect(mockRequest.write).toHaveBeenCalledWith('{"name":"test","value":123}'); + + expect(cognigy.api.output).toHaveBeenCalledWith( + "Request completed successfully", + { + success: true, + data: { + status: 201, + body: { id: 456 } + } + } + ); + }); + + it("should handle text responses", async () => { + const responseData = "Plain text response"; + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, responseData, {"content-type": "text/plain"}); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token-789" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(cognigy.api.output).toHaveBeenCalledWith( + "Request completed successfully", + { + success: true, + data: { + status: 200, + body: "Plain text response" + } + } + ); + }); + + it("should override user-provided Authorization header", async () => { + const configWithAuth = { + ...baseConfig, + headers: { + "Authorization": "Bearer user-provided-token", + "Custom-Header": "test-value" + } + }; + + const responseData = { success: true }; + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, responseData); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "correct-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithAuth as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer correct-token" // Should be the cxonetoken, not user-provided + }, + timeout: expect.any(Number) + }, expect.any(Function)); + }); + + it("should handle string body for POST request", async () => { + const postConfig = { + ...baseConfig, + method: "POST" as const, + body: "raw string body" + }; + + const responseData = { received: "ok" }; + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, responseData); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: postConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "POST", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + expect(mockRequest.write).toHaveBeenCalledWith("raw string body"); + }); + }); + + describe("error handling", () => { + it("should return structured error when cxonetoken is missing from both input and context", async () => { + const cognigy = createMockCognigy({ + input: { + data: {} // No cxonetoken + }, + context: {} // No cxonetoken in context either + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Authentication Error", + { + success: false, + error: { + type: "MissingToken", + message: "Missing cxonetoken in input.data or context.cxonetoken. This node can only be used in flows invoked by CXone with authentication.", + status: 401, + details: { + tokenSources: ["input.data.cxonetoken", "context.cxonetoken"] + }, + requestId: expect.any(String) + } + } + ); + expect(cognigy.api.log).toHaveBeenCalledWith( + "error", + expect.stringMatching(/Request req_[a-z0-9_]+: Missing cxonetoken/) + ); + }); + + it("should return structured error when cxonetoken is undefined in both locations", async () => { + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: undefined + } + }, + context: { + cxonetoken: undefined + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Authentication Error", + { + success: false, + error: { + type: "MissingToken", + message: "Missing cxonetoken in input.data or context.cxonetoken. This node can only be used in flows invoked by CXone with authentication.", + status: 401, + details: { + tokenSources: ["input.data.cxonetoken", "context.cxonetoken"] + }, + requestId: expect.any(String) + } + } + ); + }); + + it("should handle fetch errors gracefully", async () => { + setupHttpErrorMock(new Error("Network error")); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(cognigy.api.log).toHaveBeenCalledWith( + "error", + expect.stringMatching(/Request req_[a-z0-9_]+: Network error: Network error/) + ); + + expect(cognigy.api.output).toHaveBeenCalledWith( + "Request failed", + { + success: false, + error: { + type: "NetworkError", + message: "Network error: Network error", + status: 503, + details: { + isRetryable: true + }, + requestId: expect.any(String) + } + } + ); + }); + + it("should handle response parsing errors", async () => { + // Simulate a network/parsing error that http.request would emit + setupHttpErrorMock(new Error("Invalid JSON")); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(cognigy.api.output).toHaveBeenCalledWith( + "Request failed", + { + success: false, + error: { + type: "NetworkError", + message: "Network error: Invalid JSON", + status: 503, + details: { + isRetryable: true + }, + requestId: expect.any(String) + } + } + ); + }); + }); + + describe("context.cxonetoken fallback", () => { + it("should use token from context when input.data.cxonetoken is missing", async () => { + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: {} // No cxonetoken in input.data + }, + context: { + cxonetoken: "context-token-123" + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer context-token-123" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + expect(cognigy.api.log).toHaveBeenCalledWith( + "info", + expect.stringMatching(/Request req_[a-z0-9_]+: Using cxonetoken from context/) + ); + + expect(cognigy.api.output).toHaveBeenCalledWith( + "Request completed successfully", + { + success: true, + data: { + status: 200, + body: { success: true } + } + } + ); + }); + + it("should prioritize input.data.cxonetoken over context.cxonetoken", async () => { + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "input-token-priority" + } + }, + context: { + cxonetoken: "context-token-fallback" + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer input-token-priority" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + expect(cognigy.api.log).toHaveBeenCalledWith( + "info", + expect.stringMatching(/Request req_[a-z0-9_]+: Using cxonetoken from input.data/) + ); + }); + + it("should handle empty input.data and use context token", async () => { + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(201, { created: true }, {"content-type": "application/json"}); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const postConfig = { + ...baseConfig, + method: "POST" as const, + body: { test: "data" } + }; + + const cognigy = createMockCognigy({ + input: { + data: null // Null input.data + }, + context: { + cxonetoken: "context-fallback-token" + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: postConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "POST", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer context-fallback-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + // Verify body was written to the request + expect(mockRequest.write).toHaveBeenCalledWith('{"test":"data"}'); + + expect(cognigy.api.log).toHaveBeenCalledWith( + "info", + expect.stringMatching(/Request req_[a-z0-9_]+: Using cxonetoken from context/) + ); + }); + }); + + describe("HTTP methods with body support", () => { + it("should include body for GET request with JSON payload", async () => { + const getConfigWithBody = { + ...baseConfig, + method: "GET" as const, + payloadType: "json", + bodyJson: { complexQuery: { filters: ["status:active", "type:premium"] } } + }; + + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, { success: true, results: [] }); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: getConfigWithBody as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + // Verify that the JSON body was written to the request + expect(mockRequest.write).toHaveBeenCalledWith(JSON.stringify({ complexQuery: { filters: ["status:active", "type:premium"] } })); + }); + + it("should support GET request without body for backward compatibility", async () => { + const getConfigWithoutBody = { + ...baseConfig, + method: "GET" as const + // No payload configuration + }; + + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: getConfigWithoutBody as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: 8000 + }, expect.any(Function)); + }); + + it("should include body for GET request with form data payload", async () => { + const getConfigWithFormData = { + ...baseConfig, + method: "GET" as const, + payloadType: "form", + bodyForm: { search: "complex query", filters: "active,premium" } + }; + + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, { success: true, results: [] }); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: getConfigWithFormData as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + // Verify that the form data was written to the request + expect(mockRequest.write).toHaveBeenCalledWith("search=complex+query&filters=active%2Cpremium"); + }); + + it("should not include body for DELETE request", async () => { + const deleteConfig = { + ...baseConfig, + method: "DELETE" as const, + body: { shouldNotBeIncluded: true } + }; + + const mockResponse = { + status: 204, + statusText: "OK", + headers: {}, + data: "" + }; + + setupHttpMock(mockResponse.status, mockResponse.data, mockResponse.headers); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: deleteConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: 8000 + }, expect.any(Function)); + }); + }); + + describe("logging", () => { + it("should log request details and completion", async () => { + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: baseConfig as any + } as any); + + expect(cognigy.api.log).toHaveBeenCalledWith( + "info", + expect.stringMatching(/Request req_[a-z0-9_]+: Making GET request to: https:\/\/api\.example\.com\/test/) + ); + expect(cognigy.api.log).toHaveBeenCalledWith( + "info", + expect.stringMatching(/Executing GET request with timeout: \d+ms, max attempts: \d+/) + ); + expect(cognigy.api.log).toHaveBeenCalledWith( + "info", + expect.stringMatching(/Request req_[a-z0-9_]+: Completed with status 200 \(attempt \d+\/\d+\)/) + ); + }); + }); + + describe("response handling and storage", () => { + const mockResponseWithHeaders = { + status: 200, + ok: true, + headers: { + "content-type": "application/json", + "x-custom-header": "custom-value", + "server": "nginx/1.18.0" + }, + data: { success: true, data: "test" } + }; + + beforeEach(() => { + // Headers are already in plain object format for http.request, no forEach needed + }); + + it("should store response body in default context location", async () => { + setupHttpMock(mockResponseWithHeaders.status, mockResponseWithHeaders.data, mockResponseWithHeaders.headers); + + const configWithDefaults = { + ...baseConfig, + responseTarget: "context", + responseKey: "cxone.lastResponse" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithDefaults as any + } as any); + + // Should store complete response object at specified key + expect(cognigy.api.addToContext).toHaveBeenCalledWith( + "cxone.lastResponse", + { + success: true, + data: { + status: 200, + body: { success: true, data: "test" } + } + }, + "simple" + ); + }); + + it("should store response headers when enabled", async () => { + setupHttpMock(mockResponseWithHeaders.status, mockResponseWithHeaders.data, mockResponseWithHeaders.headers); + + const configWithHeaders = { + ...baseConfig, + responseTarget: "context", + responseKey: "cxone.apiCall", + storeResponseHeaders: true + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithHeaders as any + } as any); + + // Should store complete response object including headers + expect(cognigy.api.addToContext).toHaveBeenCalledWith( + "cxone.apiCall", + { + success: true, + data: { + status: 200, + body: { success: true, data: "test" }, + headers: { + "content-type": "application/json", + "x-custom-header": "custom-value", + "server": "nginx/1.18.0" + } + } + }, + "simple" + ); + }); + + it("should store response in input target", async () => { + setupHttpMock(mockResponseWithHeaders.status, mockResponseWithHeaders.data, mockResponseWithHeaders.headers); + + const configWithInput = { + ...baseConfig, + responseTarget: "input", + responseKey: "api.lastCall" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithInput as any + } as any); + + // Should store complete response object in input with specified key + expect(cognigy.input["api.lastCall"]).toEqual({ + success: true, + data: { + status: 200, + body: { success: true, data: "test" } + } + }); + }); + + + it("should handle error responses with new storage configuration", async () => { + setupHttpTimeoutMock(); + + const configWithStorage = { + ...baseConfig, + responseTarget: "context", + responseKey: "cxone.errorResponse" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithStorage as any + } as any); + + // Should store complete error response in configured location + expect(cognigy.api.addToContext).toHaveBeenCalledWith( + "cxone.errorResponse", + { + success: false, + error: { + type: "Timeout", + message: "Request timeout after 8000ms", + status: 408, + details: { + timeoutMs: 8000, + hardLimitMs: 20000, + cognigyStandard: "20-second execution budget" + }, + requestId: expect.any(String) + } + }, + "simple" + ); + + }); + + it("should not store headers when storeResponseHeaders is false", async () => { + setupHttpMock(mockResponseWithHeaders.status, mockResponseWithHeaders.data, mockResponseWithHeaders.headers); + + const configWithoutHeaders = { + ...baseConfig, + responseTarget: "context", + responseKey: "cxone.response", + storeResponseHeaders: false + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithoutHeaders as any + } as any); + + // Should store complete response object without headers + expect(cognigy.api.addToContext).toHaveBeenCalledWith( + "cxone.response", + { + success: true, + data: { + status: 200, + body: { success: true, data: "test" } + } + }, + "simple" + ); + + // Should NOT include headers in the response object + expect(cognigy.api.addToContext).toHaveBeenCalledTimes(1); // Only one call for the main response + const storedResponse = (cognigy.api.addToContext as jest.Mock).mock.calls[0][1]; + expect(storedResponse.data.headers).toBeUndefined(); + }); + + it("should handle missing responsePath gracefully", async () => { + setupHttpMock(mockResponseWithHeaders.status, mockResponseWithHeaders.data, mockResponseWithHeaders.headers); + + const configWithoutPath = { + ...baseConfig, + responseTarget: "context", + responseKey: "" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithoutPath as any + } as any); + + + // Should output normally + expect(cognigy.api.output).toHaveBeenCalledWith( + "Request completed successfully", + { + success: true, + data: { + status: 200, + body: { success: true, data: "test" } + } + } + ); + }); + }); + + describe("new payload type functionality", () => { + it("should handle JSON payload type correctly", async () => { + const postConfig = { + ...baseConfig, + method: "POST" as const, + payloadType: "json" as const, + bodyJson: { name: "test", value: 123 } + }; + + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(201, { id: 456 }); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token-456" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: postConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "POST", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token-456" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + // Verify body was written to the request + expect(mockRequest.write).toHaveBeenCalledWith('{"name":"test","value":123}'); + }); + + it("should handle text payload type correctly", async () => { + const postConfig = { + ...baseConfig, + method: "POST" as const, + payloadType: "text" as const, + bodyText: "plain text body" + }; + + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, "ok", {"content-type": "text/plain"}); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: postConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "POST", + headers: { + "Content-Type": "text/plain", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + // Verify body was written to the request + expect(mockRequest.write).toHaveBeenCalledWith("plain text body"); + }); + + it("should handle form data payload type correctly", async () => { + const postConfig = { + ...baseConfig, + method: "POST" as const, + payloadType: "form" as const, + bodyForm: { username: "testuser", password: "secret123" } + }; + + const mockRequest = createMockRequest(); + const mockResponse = createMockResponse(200, { success: true }); + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + setTimeout(() => callback(mockResponse), 0); + return mockRequest as any; + }); + + const cognigy = createMockCognigy({ + input: { + data: { + cxonetoken: "test-token" + } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: postConfig as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + + // Verify body was written to the request + expect(mockRequest.write).toHaveBeenCalledWith("username=testuser&password=secret123"); + }); + + it("should store response using simple key format in context", async () => { + const mockResponse = { + status: 200, + statusText: "OK", + headers: {"content-type": "application/json"}, + data: { result: "success" } + }; + + setupHttpMock(mockResponse.status, mockResponse.data, mockResponse.headers); + + const configSimpleKey = { + ...baseConfig, + responseTarget: "context", + responseKey: "myApiResult" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configSimpleKey as any + } as any); + + // Should store complete response object using the simple key + expect(cognigy.api.addToContext).toHaveBeenCalledWith( + "myApiResult", + { + success: true, + data: { + status: 200, + body: { result: "success" } + } + }, + "simple" + ); + }); + + it("should store response using simple key format in input", async () => { + const mockResponse = { + status: 200, + statusText: "OK", + headers: {"content-type": "application/json"}, + data: { result: "success" } + }; + + setupHttpMock(mockResponse.status, mockResponse.data, mockResponse.headers); + + const configInputKey = { + ...baseConfig, + responseTarget: "input", + responseKey: "myApiResult" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configInputKey as any + } as any); + + // Should store complete response object in input with the key + expect(cognigy.input.myApiResult).toEqual({ + success: true, + data: { + status: 200, + body: { result: "success" } + } + }); + }); + }); + + // ========== PHASE 1: UNIT TESTS ENHANCEMENT ========== + + describe("validation errors", () => { + it("should handle malformed JSON in headers field", async () => { + const configWithInvalidHeaders = { + ...baseConfig, + headers: "{ invalid json }" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithInvalidHeaders as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Configuration Error", + { + success: false, + error: { + type: "InvalidConfig", + message: "Invalid headers format - must be valid JSON", + status: 400, + details: expect.objectContaining({ + headers: "{ invalid json }", + headerError: expect.any(String) + }), + requestId: expect.any(String) + } + } + ); + }); + + it("should handle malformed JSON in bodyJson field", async () => { + const postConfig = { + ...baseConfig, + method: "POST" as const, + payloadType: "json" as const, + bodyJson: "{ malformed json }" + }; + + const { mockRequest } = setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: postConfig as any + } as any); + + // Should handle the malformed JSON gracefully by treating it as a string + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "POST", + headers: { + "Content-Type": "application/json", + "Custom-Header": "test-value", + "Authorization": "Bearer test-token" + }, + timeout: 8000 + }, expect.any(Function)); + + expect(mockRequest.write).toHaveBeenCalledWith("{ malformed json }"); + }); + + it("should reject invalid URL formats", async () => { + const configWithInvalidUrl = { + ...baseConfig, + url: "not-a-valid-url" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithInvalidUrl as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Configuration Error", + { + success: false, + error: { + type: "InvalidConfig", + message: expect.stringMatching(/Invalid URL format: not-a-valid-url/), + status: 400, + details: expect.objectContaining({ + url: "not-a-valid-url", + urlError: expect.any(String) + }), + requestId: expect.any(String) + } + } + ); + }); + + it("should handle empty/null headers configuration", async () => { + const configWithNullHeaders = { + ...baseConfig, + headers: null + }; + + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithNullHeaders as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + }); + + it("should handle header normalization from string to object", async () => { + const configWithStringHeaders = { + ...baseConfig, + headers: '{"X-Custom": "value", "Accept": "application/json"}' + }; + + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithStringHeaders as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "X-Custom": "value", + "Accept": "application/json", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + }); + + it("should handle empty URL gracefully", async () => { + const configWithEmptyUrl = { + ...baseConfig, + url: "" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithEmptyUrl as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Configuration Error", + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + type: "InvalidConfig", + message: expect.stringMatching(/Invalid URL format: /) + }) + }) + ); + }); + + it("should handle undefined configuration gracefully", async () => { + const configWithUndefinedFields = { + url: "https://api.example.com/test", + method: "GET" as const, + headers: undefined, + body: undefined + }; + + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithUndefinedFields as any + } as any); + + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer test-token" + }, + timeout: expect.any(Number) + }, expect.any(Function)); + }); + + it("should handle relative URL formats", async () => { + const configWithRelativeUrl = { + ...baseConfig, + url: "/api/test" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithRelativeUrl as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Configuration Error", + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + type: "InvalidConfig", + message: expect.stringMatching(/Invalid URL format: \/api\/test/) + }) + }) + ); + }); + + it("should handle protocol-less URLs", async () => { + const configWithProtocolLessUrl = { + ...baseConfig, + url: "api.example.com/test" + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithProtocolLessUrl as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Configuration Error", + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + type: "InvalidConfig", + message: expect.stringMatching(/Invalid URL format: api.example.com\/test/) + }) + }) + ); + }); + + it("should handle malformed JSON with syntax errors", async () => { + const configWithSyntaxError = { + ...baseConfig, + headers: '{"key": value without quotes}' + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithSyntaxError as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Configuration Error", + { + success: false, + error: { + type: "InvalidConfig", + message: "Invalid headers format - must be valid JSON", + status: 400, + details: expect.objectContaining({ + headers: '{"key": value without quotes}', + headerError: expect.any(String) + }), + requestId: expect.any(String) + } + } + ); + }); + + it("should validate complex nested headers JSON", async () => { + const configWithComplexHeaders = { + ...baseConfig, + headers: '{"Authorization": "Bearer existing", "Custom": {"nested": "value"}}' + }; + + setupHttpMock(200, { success: true }, {"content-type": "application/json"}); + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithComplexHeaders as any + } as any); + + // Complex headers are accepted and processed + expect(mockedHttps.request).toHaveBeenCalledWith({ + hostname: "api.example.com", + port: 443, + path: "/test", + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer test-token", + "Custom": { "nested": "value" } + }, + timeout: expect.any(Number) + }, expect.any(Function)); + }); + + it("should handle incomplete JSON objects", async () => { + const configWithIncompleteJson = { + ...baseConfig, + headers: '{"key": "value", "incomplete"' + }; + + const cognigy = createMockCognigy({ + input: { + data: { cxonetoken: "test-token" } + } + }); + + await cxoneAuthenticatedCall.function({ + cognigy, + config: configWithIncompleteJson as any + } as any); + + expect(mockedHttps.request).not.toHaveBeenCalled(); + expect(cognigy.api.output).toHaveBeenCalledWith( + "Configuration Error", + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + type: "InvalidConfig", + message: "Invalid headers format - must be valid JSON" + }) + }) + ); + }); + + it("should handle URL with special characters", async () => { + const configWithSpecialChars = { + ...baseConfig, + url: "https://api.example.com/test?param=value%20with%20spaces&other=