diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js index 25969facc..ff278f1e3 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js @@ -97,7 +97,7 @@ describe('lib/core/project_config/project_config_manager', function() { var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; assert.strictEqual( errorMessage, - sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR', 'projectId', 'is missing and it is required') + sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR (Project Config JSON Schema)', 'projectId', 'is missing and it is required'), ); return manager.onReady().then(function(result) { assert.include(result, { diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_schema.ts b/packages/optimizely-sdk/lib/core/project_config/project_config_schema.ts index 5830807a7..27fbb0aad 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_schema.ts +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_schema.ts @@ -21,6 +21,7 @@ import { JSONSchema4 } from 'json-schema'; var schemaDefinition = { $schema: 'http://json-schema.org/draft-04/schema#', + title: 'Project Config JSON Schema', type: 'object', properties: { projectId: { diff --git a/packages/optimizely-sdk/lib/index.browser.ts b/packages/optimizely-sdk/lib/index.browser.ts index 0db641b27..5749ebf43 100644 --- a/packages/optimizely-sdk/lib/index.browser.ts +++ b/packages/optimizely-sdk/lib/index.browser.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -18,9 +18,9 @@ import { getLogger, setErrorHandler, getErrorHandler, - LogLevel + LogLevel, } from './modules/logging'; -import { LocalStoragePendingEventsDispatcher } from '../lib/modules/event_processor'; +import { LocalStoragePendingEventsDispatcher } from './modules/event_processor'; import configValidator from './utils/config_validator'; import defaultErrorHandler from './plugins/error_handler'; import defaultEventDispatcher from './plugins/event_dispatcher/index.browser'; @@ -32,6 +32,8 @@ import { createNotificationCenter } from './core/notification_center'; import { default as eventProcessor } from './plugins/event_processor'; import { OptimizelyDecideOption, Client, Config } from './shared_types'; import { createHttpPollingDatafileManager } from './plugins/datafile_manager/http_polling_datafile_manager'; +import { EXECUTION_CONTEXT_TYPE } from './utils/enums'; +import { ExecutionContext } from './utils/execution_context'; const logger = getLogger(); logHelper.setLogHandler(loggerPlugin.createLogger()); @@ -44,11 +46,13 @@ const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; let hasRetriedEvents = false; +ExecutionContext.Current = EXECUTION_CONTEXT_TYPE.BROWSER; + /** * Creates an instance of the Optimizely class * @param {Config} config * @return {Client|null} the Optimizely client object - * null on error + * null on error */ const createInstance = function(config: Config): Client | null { try { @@ -70,6 +74,7 @@ const createInstance = function(config: Config): Client | null { try { configValidator.validate(config); isValidInstance = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (ex: any) { logger.error(ex); } @@ -141,11 +146,13 @@ const createInstance = function(config: Config): Client | null { false ); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { logger.error(enums.LOG_MESSAGES.UNABLE_TO_ATTACH_UNLOAD, MODULE_NAME, e.message); } return optimizely; + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { logger.error(e); return null; diff --git a/packages/optimizely-sdk/lib/index.node.ts b/packages/optimizely-sdk/lib/index.node.ts index a92fe1e91..6fe59eb22 100644 --- a/packages/optimizely-sdk/lib/index.node.ts +++ b/packages/optimizely-sdk/lib/index.node.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * * You may obtain a copy of the License at * * * - * http://www.apache.org/licenses/LICENSE-2.0 * + * https://www.apache.org/licenses/LICENSE-2.0 * * * * Unless required by applicable law or agreed to in writing, software * * distributed under the License is distributed on an "AS IS" BASIS, * @@ -32,6 +32,8 @@ import { createNotificationCenter } from './core/notification_center'; import { createEventProcessor } from './plugins/event_processor'; import { OptimizelyDecideOption, Client, Config } from './shared_types'; import { createHttpPollingDatafileManager } from './plugins/datafile_manager/http_polling_datafile_manager'; +import { ExecutionContext } from './utils/execution_context'; +import { EXECUTION_CONTEXT_TYPE } from './utils/enums'; const logger = getLogger(); setLogLevel(LogLevel.ERROR); @@ -40,13 +42,15 @@ const DEFAULT_EVENT_BATCH_SIZE = 10; const DEFAULT_EVENT_FLUSH_INTERVAL = 30000; // Unit is ms, default is 30s const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; +ExecutionContext.Current = EXECUTION_CONTEXT_TYPE.NODE; + /** * Creates an instance of the Optimizely class * @param {Config} config * @return {Client|null} the Optimizely client object - * null on error + * null on error */ - const createInstance = function(config: Config): Client | null { +const createInstance = function(config: Config): Client | null { try { let hasLogger = false; let isValidInstance = false; @@ -68,6 +72,7 @@ const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; try { configValidator.validate(config); isValidInstance = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (ex: any) { if (hasLogger) { logger.error(ex); @@ -117,6 +122,7 @@ const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; }; return new Optimizely(optimizelyOptions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { logger.error(e); return null; diff --git a/packages/optimizely-sdk/lib/index.react_native.ts b/packages/optimizely-sdk/lib/index.react_native.ts index 41ed88e46..cece646f2 100644 --- a/packages/optimizely-sdk/lib/index.react_native.ts +++ b/packages/optimizely-sdk/lib/index.react_native.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -32,6 +32,8 @@ import { createNotificationCenter } from './core/notification_center'; import { createEventProcessor } from './plugins/event_processor/index.react_native'; import { OptimizelyDecideOption, Client, Config } from './shared_types'; import { createHttpPollingDatafileManager } from './plugins/datafile_manager/http_polling_datafile_manager'; +import { EXECUTION_CONTEXT_TYPE } from './utils/enums'; +import { ExecutionContext } from './utils/execution_context'; const logger = getLogger(); setLogHandler(loggerPlugin.createLogger()); @@ -41,13 +43,15 @@ const DEFAULT_EVENT_BATCH_SIZE = 10; const DEFAULT_EVENT_FLUSH_INTERVAL = 1000; // Unit is ms, default is 1s const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; +ExecutionContext.Current = EXECUTION_CONTEXT_TYPE.BROWSER; + /** * Creates an instance of the Optimizely class * @param {Config} config * @return {Client|null} the Optimizely client object - * null on error + * null on error */ - const createInstance = function(config: Config): Client | null { +const createInstance = function(config: Config): Client | null { try { // TODO warn about setting per instance errorHandler / logger / logLevel let isValidInstance = false; @@ -67,6 +71,7 @@ const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; try { configValidator.validate(config); isValidInstance = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (ex: any) { logger.error(ex); } @@ -117,6 +122,7 @@ const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; } return new Optimizely(optimizelyOptions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { logger.error(e); return null; diff --git a/packages/optimizely-sdk/lib/plugins/odp/graphql_manager.ts b/packages/optimizely-sdk/lib/plugins/odp/graphql_manager.ts new file mode 100644 index 000000000..fc0160d05 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/graphql_manager.ts @@ -0,0 +1,137 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorHandler, LogHandler, LogLevel } from '../../modules/logging'; +import { Response } from './odp_types'; +import { IOdpClient, OdpClient } from './odp_client'; +import { validate } from '../../utils/json_schema_validator'; +import { OdpResponseSchema } from './odp_response_schema'; +import { QuerySegmentsParameters } from './query_segments_parameters'; +import { RequestHandlerFactory } from '../../utils/http_request_handler/request_handler_factory'; + +/** + * Expected value for a qualified/valid segment + */ +const QUALIFIED = 'qualified'; +/** + * Return value when no valid segments found + */ +const EMPTY_SEGMENTS_COLLECTION: string[] = []; +/** + * Return value for scenarios with no valid JSON + */ +const EMPTY_JSON_RESPONSE = null; + +/** + * Manager for communicating with the Optimizely Data Platform GraphQL endpoint + */ +export interface IGraphQLManager { + fetchSegments(apiKey: string, apiHost: string, userKey: string, userValue: string, segmentsToCheck: string[]): Promise; +} + +/** + * Concrete implementation for communicating with the Optimizely Data Platform GraphQL endpoint + */ +export class GraphqlManager implements IGraphQLManager { + private readonly _errorHandler: ErrorHandler; + private readonly _logger: LogHandler; + private readonly _odpClient: IOdpClient; + + /** + * Retrieves the audience segments from the Optimizely Data Platform (ODP) + * @param errorHandler Handler to record exceptions + * @param logger Collect and record events/errors for this GraphQL implementation + * @param client Client to use to send queries to ODP + */ + constructor(errorHandler: ErrorHandler, logger: LogHandler, client?: IOdpClient) { + this._errorHandler = errorHandler; + this._logger = logger; + + this._odpClient = client ?? new OdpClient(this._errorHandler, + this._logger, + RequestHandlerFactory.createHandler(this._logger)); + } + + /** + * Retrieves the audience segments from ODP + * @param apiKey ODP public key + * @param apiHost Fully-qualified URL of ODP + * @param userKey 'vuid' or 'fs_user_id key' + * @param userValue Associated value to query for the user key + * @param segmentsToCheck Audience segments to check for experiment inclusion + */ + public async fetchSegments(apiKey: string, apiHost: string, userKey: string, userValue: string, segmentsToCheck: string[]): Promise { + const parameters = new QuerySegmentsParameters({ + apiKey, + apiHost, + userKey, + userValue, + segmentsToCheck, + }); + const segmentsResponse = await this._odpClient.querySegments(parameters); + if (!segmentsResponse) { + this._logger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)'); + return EMPTY_SEGMENTS_COLLECTION; + } + + const parsedSegments = this.parseSegmentsResponseJson(segmentsResponse); + if (!parsedSegments) { + this._logger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)'); + return EMPTY_SEGMENTS_COLLECTION; + } + + if (parsedSegments.errors?.length > 0) { + const errors = parsedSegments.errors.map((e) => e.message).join('; '); + + this._logger.log(LogLevel.WARNING, `Audience segments fetch failed (${errors})`); + + return EMPTY_SEGMENTS_COLLECTION; + } + + const edges = parsedSegments?.data?.customer?.audiences?.edges; + if (!edges) { + this._logger.log(LogLevel.WARNING, 'Audience segments fetch failed (decode error)'); + return EMPTY_SEGMENTS_COLLECTION; + } + + return edges.filter(edge => edge.node.state == QUALIFIED).map(edge => edge.node.name); + } + + /** + * Parses JSON response + * @param jsonResponse JSON response from ODP + * @private + * @returns Response Strongly-typed ODP Response object + */ + private parseSegmentsResponseJson(jsonResponse: string): Response | null { + let jsonObject = {}; + + try { + jsonObject = JSON.parse(jsonResponse); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this._errorHandler.handleError(error); + this._logger.log(LogLevel.ERROR, 'Attempted to parse invalid segment response JSON.'); + return EMPTY_JSON_RESPONSE; + } + + if (validate(jsonObject, OdpResponseSchema, false)) { + return jsonObject as Response; + } + + return EMPTY_JSON_RESPONSE; + } +} diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_client.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_client.ts new file mode 100644 index 000000000..416f312b5 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/odp_client.ts @@ -0,0 +1,94 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorHandler, LogHandler, LogLevel } from '../../modules/logging'; +import { QuerySegmentsParameters } from './query_segments_parameters'; +import { RequestHandler, Response } from '../../utils/http_request_handler/http'; +import { REQUEST_TIMEOUT_MS } from '../../utils/http_request_handler/config'; + +/** + * Standard failure message for fetch errors + */ +const FETCH_FAILURE_MESSAGE = 'Audience segments fetch failed'; +/** + * Return value for scenarios with no valid JSON + */ +const EMPTY_JSON_RESPONSE = null; + +/** + * Interface for sending requests and handling responses to Optimizely Data Platform + */ +export interface IOdpClient { + querySegments(parameters: QuerySegmentsParameters): Promise; +} + +/** + * Http implementation for sending requests and handling responses to Optimizely Data Platform + */ +export class OdpClient implements IOdpClient { + private readonly _errorHandler: ErrorHandler; + private readonly _logger: LogHandler; + private readonly _timeout: number; + private readonly _requestHandler: RequestHandler; + + /** + * An implementation for sending requests and handling responses to Optimizely Data Platform (ODP) + * @param errorHandler Handler to record exceptions + * @param logger Collect and record events/errors for this ODP client + * @param requestHandler Client implementation to send/receive requests over HTTP + * @param timeout Maximum milliseconds before requests are considered timed out + */ + constructor(errorHandler: ErrorHandler, logger: LogHandler, requestHandler: RequestHandler, timeout: number = REQUEST_TIMEOUT_MS) { + this._errorHandler = errorHandler; + this._logger = logger; + this._requestHandler = requestHandler; + this._timeout = timeout; + } + + /** + * Handler for querying the ODP GraphQL endpoint + * @param parameters + * @returns JSON response string from ODP + */ + public async querySegments(parameters: QuerySegmentsParameters): Promise { + if (!parameters?.apiHost || !parameters?.apiKey) { + this._logger.log(LogLevel.ERROR, 'No ApiHost or ApiKey set before querying segments'); + return EMPTY_JSON_RESPONSE; + } + + const method = 'POST'; + const url = parameters.apiHost; + const headers = { + 'Content-Type': 'application/json', + 'x-api-key': parameters.apiKey, + }; + const data = parameters.toGraphQLJson(); + + let response: Response; + try { + const request = this._requestHandler.makeRequest(url, headers, method, data); + response = await request.responsePromise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this._errorHandler.handleError(error); + this._logger.log(LogLevel.ERROR, `${FETCH_FAILURE_MESSAGE} (${error.statusCode ?? 'network error'})`); + + return EMPTY_JSON_RESPONSE; + } + + return response.body; + } +} diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_response_schema.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_response_schema.ts new file mode 100644 index 000000000..98a16b4c3 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/odp_response_schema.ts @@ -0,0 +1,186 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JSONSchema4 } from 'json-schema'; + +/** + * ODP Response JSON Schema file used to validate the project json datafile + */ +export const OdpResponseSchema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + $id: 'https://example.com/example.json', + title: 'ODP Response Schema', + type: 'object', + required: [ + 'data', + ], + properties: { + data: { + title: 'The data Schema', + type: 'object', + required: [ + 'customer', + ], + properties: { + customer: { + title: 'The customer Schema', + type: 'object', + required: [ ], + properties: { + audiences: { + title: 'The audiences Schema', + type: 'object', + required: [ + 'edges', + ], + properties: { + edges: { + title: 'The edges Schema', + type: 'array', + items: { + title: 'A Schema', + type: 'object', + required: [ + 'node', + ], + properties: { + node: { + title: 'The node Schema', + type: 'object', + required: [ + 'name', + 'state', + ], + properties: { + name: { + title: 'The name Schema', + type: 'string', + examples: [ + 'has_email', + 'has_email_opted_in', + ], + }, + state: { + title: 'The state Schema', + type: 'string', + examples: [ + 'qualified', + ], + }, + }, + examples: [], + }, + }, + examples: [], + }, + examples: [], + }, + }, + examples: [], + }, + }, + examples: [], + }, + }, + examples: [], + }, + errors: { + title: 'The errors Schema', + type: 'array', + default: [], + items: { + title: 'A Schema', + type: 'object', + required: [ + 'message', + 'locations', + 'extensions' + ], + properties: { + message: { + title: 'The message Schema', + type: 'string', + examples: [ + 'Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd' + ] + }, + locations: { + title: 'The locations Schema', + type: 'array', + items: { + title: 'A Schema', + type: 'object', + required: [ + 'line', + 'column' + ], + properties: { + line: { + title: 'The line Schema', + type: 'integer', + examples: [ + 2 + ] + }, + column: { + title: 'The column Schema', + type: 'integer', + examples: [ + 3 + ] + } + }, + examples: [] + }, + examples: [] + }, + path: { + title: 'The path Schema', + type: 'array', + items: { + title: 'A Schema', + type: 'string', + examples: [ + 'customer' + ] + }, + examples: [] + }, + extensions: { + title: 'The extensions Schema', + type: 'object', + required: [ + 'classification' + ], + properties: { + classification: { + title: 'The classification Schema', + type: 'string', + examples: [ + 'InvalidIdentifierException' + ] + } + }, + examples: [] + } + }, + examples: [] + }, + examples: [] + }, + }, + examples: [], +} as JSONSchema4; diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_types.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_types.ts new file mode 100644 index 000000000..cb034a3c9 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/odp_types.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Wrapper around valid data and error responses + */ +export interface Response { + data: Data; + errors: Error[]; +} + +/** + * GraphQL response data returned from a valid query + */ +export interface Data { + customer: Customer; +} + +/** + * GraphQL response from an errant query + */ +export interface Error { + message: string; + locations: Location[]; + path: string[]; + extensions: Extension; +} + +/** + * Profile used to group/segment an addressable market + */ +export interface Customer { + audiences: Audience; +} + +/** + * Specifies the precise place in code or data where the error occurred + */ +export interface Location { + line: number; + column: number; +} + +/** + * Extended error information + */ +export interface Extension { + classification: string; +} + +/** + * Segment of a customer base + */ +export interface Audience { + edges: Edge[]; +} + +/** + * Grouping of nodes within an audience + */ +export interface Edge { + node: Node; +} + +/** + * Atomic grouping an audience + */ +export interface Node { + name: string; + state: string; +} diff --git a/packages/optimizely-sdk/lib/plugins/odp/query_segments_parameters.ts b/packages/optimizely-sdk/lib/plugins/odp/query_segments_parameters.ts new file mode 100644 index 000000000..aa12df677 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/query_segments_parameters.ts @@ -0,0 +1,66 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Handles parameters used in querying ODP segments + */ +export class QuerySegmentsParameters { + /** + * Optimizely Data Platform API key + */ + public apiKey?: string; + + /** + * Fully-qualified URL to ODP endpoint + */ + public apiHost: string | undefined; + + /** + * 'vuid' or 'fs_user_id' (client device id or fullstack id) + */ + public userKey: string | undefined; + + /** + * Value for the user key + */ + public userValue: string | undefined; + + /** + * Audience segments to check for inclusion in the experiment + */ + public segmentsToCheck: string[] | undefined; + + constructor(parameters: { apiKey: string, apiHost: string, userKey: string, userValue: string, segmentsToCheck: string[] }) { + Object.assign(this, parameters); + } + + /** + * Converts the QuerySegmentsParameters to a GraphQL JSON payload + * @returns GraphQL JSON string + */ + public toGraphQLJson(): string { + const segmentsArrayJson = JSON.stringify(this.segmentsToCheck); + + const json: string[] = []; + json.push('{"query" : "query {customer"'); + json.push(`(${this.userKey} : "${this.userValue}") `); + json.push('{audiences'); + json.push(`(subset: ${segmentsArrayJson})`); + json.push('{edges {node {name state}}}}}"}'); + + return json.join(''); + } +} diff --git a/packages/optimizely-sdk/lib/utils/enums/index.ts b/packages/optimizely-sdk/lib/utils/enums/index.ts index fe4abb242..e9eecdcf4 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.ts +++ b/packages/optimizely-sdk/lib/utils/enums/index.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * * You may obtain a copy of the License at * * * - * http://www.apache.org/licenses/LICENSE-2.0 * + * https://www.apache.org/licenses/LICENSE-2.0 * * * * Unless required by applicable law or agreed to in writing, software * * distributed under the License is distributed on an "AS IS" BASIS, * @@ -291,3 +291,13 @@ export enum NOTIFICATION_TYPES { OPTIMIZELY_CONFIG_UPDATE = 'OPTIMIZELY_CONFIG_UPDATE', TRACK = 'TRACK:event_key, user_id, attributes, event_tags, event', } + + +/** + * Valid types of Javascript contexts in which this code is executing + */ +export enum EXECUTION_CONTEXT_TYPE { + NOT_DEFINED, + BROWSER, + NODE, +} diff --git a/packages/optimizely-sdk/lib/utils/execution_context/index.ts b/packages/optimizely-sdk/lib/utils/execution_context/index.ts new file mode 100644 index 000000000..f9f3e2457 --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/execution_context/index.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EXECUTION_CONTEXT_TYPE } from '../enums'; + +/** + * Determine the running or execution context for JavaScript + * Note: React Native is considered a browser context + */ +export class ExecutionContext { + /** + * Holds the current value of the execution context + * @private + */ + private static _currentContext: EXECUTION_CONTEXT_TYPE = EXECUTION_CONTEXT_TYPE.NOT_DEFINED; + + /** + * Gets the current running context + * @constructor + */ + public static get Current(): EXECUTION_CONTEXT_TYPE { + return this._currentContext; + } + + /** + * Sets the current running context ideally from package initialization + * @param newValue The new execution context + * @constructor + */ + public static set Current(newValue: EXECUTION_CONTEXT_TYPE) { + this._currentContext = newValue; + } +} diff --git a/packages/optimizely-sdk/lib/utils/fns/index.ts b/packages/optimizely-sdk/lib/utils/fns/index.ts index 59084a130..c769f147b 100644 --- a/packages/optimizely-sdk/lib/utils/fns/index.ts +++ b/packages/optimizely-sdk/lib/utils/fns/index.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -141,14 +141,14 @@ export function keyByUtil(arr: K[], keyByFn: (item: K) => string): { [key: st export function sprintf(format: string, ...args: any[]): string { let i = 0 return format.replace(/%s/g, function() { - const arg = args[i++] - const type = typeof arg + const arg = args[i++]; + const type = typeof arg; if (type === 'function') { - return arg() + return arg(); } else if (type === 'string') { - return arg + return arg; } else { - return String(arg) + return String(arg); } }) } @@ -167,5 +167,5 @@ export default { objectEntries, find, keyByUtil, - sprintf -} + sprintf, +}; diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/browser_request_handler.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/browser_request_handler.ts new file mode 100644 index 000000000..3103b5322 --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/browser_request_handler.ts @@ -0,0 +1,132 @@ +/** + * Copyright 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AbortableRequest, Headers, RequestHandler, Response } from './http'; +import { REQUEST_TIMEOUT_MS } from './config'; +import { LogHandler, LogLevel } from '../../modules/logging'; + +const READY_STATE_DONE = 4; + +/** + * Handles sending requests and receiving responses over HTTP via XMLHttpRequest + */ +export class BrowserRequestHandler implements RequestHandler { + private readonly _logger: LogHandler; + private readonly _timeout: number; + + public constructor(logger: LogHandler, timeout: number = REQUEST_TIMEOUT_MS) { + this._logger = logger; + this._timeout = timeout; + } + + /** + * Builds an XMLHttpRequest + * @param reqUrl Fully-qualified URL to which to send the request + * @param headers List of headers to include in the request + * @param method HTTP method to use + * @param data stringified version of data to POST, PUT, etc + * @returns AbortableRequest contains both the response Promise and capability to abort() + */ + public makeRequest(reqUrl: string, headers: Headers, method: string, data?: string): AbortableRequest { + const request = new XMLHttpRequest(); + + const responsePromise: Promise = new Promise((resolve, reject) => { + request.open(method, reqUrl, true); + + this.setHeadersInXhr(headers, request); + + request.onreadystatechange = (): void => { + if (request.readyState === READY_STATE_DONE) { + const statusCode = request.status; + if (statusCode === 0) { + reject(new Error('Request error')); + return; + } + + const headers = this.parseHeadersFromXhr(request); + const response: Response = { + statusCode: request.status, + body: request.responseText, + headers, + }; + resolve(response); + } + }; + + request.timeout = this._timeout; + + request.ontimeout = (): void => { + this._logger.log(LogLevel.WARNING, 'Request timed out'); + }; + + request.send(data); + }); + + return { + responsePromise, + abort(): void { + request.abort(); + }, + }; + } + + /** + * Sets the header collection for an XHR + * @param headers Headers to set + * @param request Request into which headers are to be set + * @private + */ + private setHeadersInXhr(headers: Headers, request: XMLHttpRequest): void { + Object.keys(headers).forEach(headerName => { + const header = headers[headerName]; + if (typeof header === 'string') { + request.setRequestHeader(headerName, header); + } + }); + } + + /** + * Parses headers from an XHR + * @param request Request containing headers to be retrieved + * @private + * @returns List of headers without duplicates + */ + private parseHeadersFromXhr(request: XMLHttpRequest): Headers { + const allHeadersString = request.getAllResponseHeaders(); + + if (allHeadersString === null) { + return {}; + } + + const headerLines = allHeadersString.split('\r\n'); + const headers: Headers = {}; + headerLines.forEach(headerLine => { + try { + const separatorIndex = headerLine.indexOf(': '); + if (separatorIndex > -1) { + const headerName = headerLine.slice(0, separatorIndex); + const headerValue = headerLine.slice(separatorIndex + 2); + if (headerName && headerValue) { + headers[headerName] = headerValue; + } + } + } catch { + this._logger.log(LogLevel.WARNING, `Unable to parse & skipped header item '${headerLine}'`); + } + }); + return headers; + } +} diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/config.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/config.ts new file mode 100644 index 000000000..76ab92f82 --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/config.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2019-2020, 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Default milliseconds before request timeout + */ +export const REQUEST_TIMEOUT_MS = 60 * 1000; // 1 minute diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/http.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/http.ts new file mode 100644 index 000000000..4eafb07fd --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/http.ts @@ -0,0 +1,47 @@ +/** + * Copyright 2019-2020, 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * List of key-value pairs to be used in an HTTP requests + */ +export interface Headers { + [header: string]: string | undefined; +} + +/** + * Simplified Response object containing only needed information + */ +export interface Response { + statusCode?: number; + body: string; + headers: Headers; +} + +/** + * Cancellable request wrapper around a Promised response + */ +export interface AbortableRequest { + abort(): void; + + responsePromise: Promise; +} + +/** + * Client that handles sending requests and receiving responses + */ +export interface RequestHandler { + makeRequest(reqUrl: string, headers: Headers, method: string, data?: string): AbortableRequest; +} diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/node_request_handler.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/node_request_handler.ts new file mode 100644 index 000000000..5a6c647f1 --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/node_request_handler.ts @@ -0,0 +1,177 @@ +/** + * Copyright 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import http from 'http'; +import https from 'https'; +import url from 'url'; +import { AbortableRequest, Headers, RequestHandler, Response } from './http'; +import { REQUEST_TIMEOUT_MS } from './config'; +import decompressResponse from 'decompress-response'; +import { LogHandler } from '../../modules/logging'; + +/** + * Handles sending requests and receiving responses over HTTP via NodeJS http module + */ +export class NodeRequestHandler implements RequestHandler { + private readonly _logger: LogHandler; + private readonly _timeout: number; + + public constructor(logger: LogHandler, timeout: number = REQUEST_TIMEOUT_MS) { + this._logger = logger; + this._timeout = timeout; + } + + /** + * Builds an XMLHttpRequest + * @param reqUrl Fully-qualified URL to which to send the request + * @param headers List of headers to include in the request + * @param method HTTP method to use + * @param data stringified version of data to POST, PUT, etc + * @returns AbortableRequest contains both the response Promise and capability to abort() + */ + public makeRequest(reqUrl: string, headers: Headers, method: string, data?: string): AbortableRequest { + const parsedUrl = url.parse(reqUrl); + + if (parsedUrl.protocol !== 'https:') { + return { + responsePromise: Promise.reject(new Error(`Unsupported protocol: ${parsedUrl.protocol}`)), + abort: () => { + }, + }; + } + + const request = https.request({ + ...this.getRequestOptionsFromUrl(parsedUrl), + method, + headers: { + ...headers, + 'accept-encoding': 'gzip,deflate', + }, + timeout: this._timeout, + }); + const responsePromise = this.getResponseFromRequest(request); + + if (data) { + request.write(data); + } + request.end(); + + return { + abort: () => request.destroy(), + responsePromise, + }; + } + + /** + * Parses a URL into its constituent parts + * @param url URL object to parse + * @private + * @returns https.RequestOptions Standard request options dictionary + */ + private getRequestOptionsFromUrl(url: url.UrlWithStringQuery): https.RequestOptions { + return { + hostname: url.hostname, + path: url.path, + port: url.port, + protocol: url.protocol, + }; + } + + /** + * Parses headers from an http response + * @param incomingMessage Incoming response message to parse + * @private + * @returns Headers Dictionary of headers without duplicates + */ + private createHeadersFromNodeIncomingMessage(incomingMessage: http.IncomingMessage): Headers { + const headers: Headers = {}; + Object.keys(incomingMessage.headers).forEach(headerName => { + const headerValue = incomingMessage.headers[headerName]; + if (typeof headerValue === 'string') { + headers[headerName] = headerValue; + } else if (typeof headerValue === 'undefined') { + // no value provided for this header + } else { + // array + if (headerValue.length > 0) { + // We don't care about multiple values - just take the first one + headers[headerName] = headerValue[0]; + } + } + }); + return headers; + } + + /** + * Sends a built request handling response, errors, and events around the transmission + * @param request Request to send + * @private + * @returns Response Promise-wrapped, simplified response object + */ + private getResponseFromRequest(request: http.ClientRequest): Promise { + return new Promise((resolve, reject) => { + request.on('timeout', () => { + request.destroy(); + reject(new Error('Request timed out')); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request.on('error', (err: any) => { + if (err instanceof Error) { + reject(err); + } else if (typeof err === 'string') { + reject(new Error(err)); + } else { + reject(new Error('Request error')); + } + }); + + request.once('response', (incomingMessage: http.IncomingMessage) => { + if (request.destroyed) { + return; + } + + const response = decompressResponse(incomingMessage); + + response.setEncoding('utf8'); + + let responseData = ''; + response.on('data', (chunk: string) => { + if (!request.destroyed) { + responseData += chunk; + } + }); + + response.on('end', () => { + if (request.destroyed) { + return; + } + + resolve({ + statusCode: incomingMessage.statusCode, + body: responseData, + headers: this.createHeadersFromNodeIncomingMessage(incomingMessage), + }); + }); + }); + }); + } +} + + + + + diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/request_handler_factory.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/request_handler_factory.ts new file mode 100644 index 000000000..455754cb5 --- /dev/null +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/request_handler_factory.ts @@ -0,0 +1,38 @@ +/** + * Copyright 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LogHandler } from '../../modules/logging'; +import { RequestHandler } from './http'; +import { NodeRequestHandler } from './node_request_handler'; +import { BrowserRequestHandler } from './browser_request_handler'; +import { ExecutionContext } from '../execution_context'; +import { EXECUTION_CONTEXT_TYPE } from '../enums'; + +/** + * Factory to create the appropriate type of RequestHandler based on a provided context + */ +export class RequestHandlerFactory { + public static createHandler(logger: LogHandler, timeout?: number): RequestHandler { + switch (ExecutionContext.Current) { + case EXECUTION_CONTEXT_TYPE.BROWSER: + return new BrowserRequestHandler(logger, timeout); + case EXECUTION_CONTEXT_TYPE.NODE: + return new NodeRequestHandler(logger, timeout); + default: + return null as unknown as RequestHandler; + } + } +} diff --git a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js index 00d9e712b..597ce15b7 100644 --- a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '../../utils/fns'; +import { sprintf } from '../fns'; import { assert } from 'chai'; import { validate } from './'; @@ -33,7 +33,7 @@ describe('lib/utils/json_schema_validator', function() { it('should throw an error if no json object is passed in', function() { assert.throws(function() { validate(); - }, sprintf(ERROR_MESSAGES.NO_JSON_PROVIDED, 'JSON_SCHEMA_VALIDATOR')); + }, sprintf(ERROR_MESSAGES.NO_JSON_PROVIDED, 'JSON_SCHEMA_VALIDATOR (Project Config JSON Schema)')); }); it('should validate specified Optimizely datafile', function() { diff --git a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts index 95e4c504f..96c3dc485 100644 --- a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts +++ b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { sprintf } from '../fns'; -import { validate as jsonSchemaValidator } from 'json-schema'; +import { JSONSchema4, validate as jsonSchemaValidator } from 'json-schema'; import { ERROR_MESSAGES } from '../enums'; import schema from '../../core/project_config/project_config_schema'; @@ -23,23 +23,32 @@ const MODULE_NAME = 'JSON_SCHEMA_VALIDATOR'; /** * Validate the given json object against the specified schema - * @param {unknown} jsonObject The object to validate against the schema - * @return {boolean} true if the given object is valid + * @param {unknown} jsonObject The object to validate against the schema + * @param {JSONSchema4} validationSchema Provided schema to use for validation + * @param {boolean} shouldThrowOnError Should validation throw if invalid JSON object + * @return {boolean} true if the given object is valid; throws or false if invalid */ -export function validate(jsonObject: unknown): boolean { +export function validate(jsonObject: unknown, validationSchema: JSONSchema4 = schema, shouldThrowOnError = true): boolean { + const moduleTitle = `${MODULE_NAME} (${validationSchema.title})`; + if (typeof jsonObject !== 'object' || jsonObject === null) { - throw new Error(sprintf(ERROR_MESSAGES.NO_JSON_PROVIDED, MODULE_NAME)); + throw new Error(sprintf(ERROR_MESSAGES.NO_JSON_PROVIDED, moduleTitle)); } - const result = jsonSchemaValidator(jsonObject, schema); + const result = jsonSchemaValidator(jsonObject, validationSchema); if (result.valid) { return true; - } else { - if (Array.isArray(result.errors)) { - throw new Error( - sprintf(ERROR_MESSAGES.INVALID_DATAFILE, MODULE_NAME, result.errors[0].property, result.errors[0].message) - ); - } - throw new Error(sprintf(ERROR_MESSAGES.INVALID_JSON, MODULE_NAME)); } + + if (!shouldThrowOnError) { + return false; + } + + if (Array.isArray(result.errors)) { + throw new Error( + sprintf(ERROR_MESSAGES.INVALID_DATAFILE, moduleTitle, result.errors[0].property, result.errors[0].message), + ); + } + + throw new Error(sprintf(ERROR_MESSAGES.INVALID_JSON, moduleTitle)); } diff --git a/packages/optimizely-sdk/package-lock.json b/packages/optimizely-sdk/package-lock.json index fadc7ed7e..64a02bde5 100644 --- a/packages/optimizely-sdk/package-lock.json +++ b/packages/optimizely-sdk/package-lock.json @@ -10,7 +10,6 @@ "license": "Apache-2.0", "dependencies": { "@optimizely/js-sdk-datafile-manager": "^0.9.5", - "@optimizely/js-sdk-event-processor": "^0.9.2", "json-schema": "^0.4.0", "murmurhash": "^2.0.1", "uuid": "^8.3.2" @@ -23,6 +22,7 @@ "@types/chai": "^4.2.11", "@types/jest": "^23.3.12", "@types/mocha": "^5.2.7", + "@types/nise": "^1.4.0", "@types/uuid": "^3.4.4", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", @@ -41,7 +41,8 @@ "lodash": "^4.17.11", "mocha": "^5.2.0", "mocha-lcov-reporter": "^1.3.0", - "nock": "^7.7.2", + "nise": "^5.1.1", + "nock": "^13.2.9", "nyc": "^15.0.1", "promise-polyfill": "8.1.0", "rollup": "2.2.0", @@ -4149,6 +4150,30 @@ "devOptional": true, "peer": true }, + "node_modules/@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", + "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@types/chai": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.3.tgz", @@ -4254,6 +4279,12 @@ "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", "dev": true }, + "node_modules/@types/nise": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@types/nise/-/nise-1.4.0.tgz", + "integrity": "sha512-DPxmjiDwubsNmguG5X4fEJ+XCyzWM3GXWsqQlvUcjJKa91IOoJUy51meDr0GkzK64qqNcq85ymLlyjoct9tInw==", + "dev": true + }, "node_modules/@types/node": { "version": "18.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.2.tgz", @@ -7277,23 +7308,6 @@ "node": ">=0.12" } }, - "node_modules/deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", - "dev": true, - "dependencies": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -10048,22 +10062,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -11898,6 +11896,12 @@ "node": ">=0.6.0" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/karma": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.0.tgz", @@ -14834,6 +14838,19 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "devOptional": true }, + "node_modules/nise": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", + "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": ">=5", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "node_modules/nocache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", @@ -14845,87 +14862,18 @@ } }, "node_modules/nock": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/nock/-/nock-7.7.3.tgz", - "integrity": "sha512-YZawhm3nkypLhoVpQq/8h1CilR2/B2vuSrA7cM/8wlZsMYi/czkENRR05MO2rI6vhPT4YCVjbP8R8C0uhY80lw==", + "version": "13.2.9", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.9.tgz", + "integrity": "sha512-1+XfJNYF1cjGB+TKMWi29eZ0b82QOvQs2YoLNzbpWGqFMtRQHTa57osqdGj4FrFPgkO4D4AZinzUJR9VvW3QUA==", "dev": true, - "engines": [ - "node >= 0.10.0" - ], "dependencies": { - "chai": ">=1.9.2 <4.0.0", - "debug": "^2.2.0", - "deep-equal": "^1.0.0", + "debug": "^4.1.0", "json-stringify-safe": "^5.0.1", - "lodash": "^3.10.1", - "mkdirp": "^0.5.0", - "propagate": "0.3.x", - "qs": "^6.0.2" - } - }, - "node_modules/nock/node_modules/chai": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", - "integrity": "sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==", - "dev": true, - "dependencies": { - "assertion-error": "^1.0.1", - "deep-eql": "^0.1.3", - "type-detect": "^1.0.0" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/nock/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/nock/node_modules/deep-eql": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "integrity": "sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==", - "dev": true, - "dependencies": { - "type-detect": "0.1.1" + "lodash": "^4.17.21", + "propagate": "^2.0.0" }, "engines": { - "node": "*" - } - }, - "node_modules/nock/node_modules/deep-eql/node_modules/type-detect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", - "integrity": "sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/nock/node_modules/lodash": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", - "integrity": "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ==", - "dev": true - }, - "node_modules/nock/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/nock/node_modules/type-detect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", - "integrity": "sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA==", - "dev": true, - "engines": { - "node": "*" + "node": ">= 10.13" } }, "node_modules/node-dir": { @@ -15604,22 +15552,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -16379,13 +16311,13 @@ } }, "node_modules/propagate": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-0.3.1.tgz", - "integrity": "sha512-GOc8Eoa3MWbN905b5l2PNNt1Pf+I/CF6uAPt3IGT+v9WExDE7WPT/kDfLe7vYXtG11KbTnBhTNfcoq9+umDrSw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true, - "engines": [ - "node >= 0.8.1" - ] + "engines": { + "node": ">= 8" + } }, "node_modules/ps-tree": { "version": "1.2.0", @@ -23507,6 +23439,30 @@ "devOptional": true, "peer": true }, + "@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", + "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "@types/chai": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.3.tgz", @@ -23612,6 +23568,12 @@ "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", "dev": true }, + "@types/nise": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@types/nise/-/nise-1.4.0.tgz", + "integrity": "sha512-DPxmjiDwubsNmguG5X4fEJ+XCyzWM3GXWsqQlvUcjJKa91IOoJUy51meDr0GkzK64qqNcq85ymLlyjoct9tInw==", + "dev": true + }, "@types/node": { "version": "18.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.2.tgz", @@ -26067,20 +26029,6 @@ "type-detect": "^4.0.0" } }, - "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", - "dev": true, - "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" - } - }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -28180,16 +28128,6 @@ "kind-of": "^3.0.2" } }, - "is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -29716,6 +29654,12 @@ "verror": "1.10.0" } }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "karma": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.0.tgz", @@ -32076,6 +32020,19 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "devOptional": true }, + "nise": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", + "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": ">=5", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "nocache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", @@ -32084,76 +32041,15 @@ "peer": true }, "nock": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/nock/-/nock-7.7.3.tgz", - "integrity": "sha512-YZawhm3nkypLhoVpQq/8h1CilR2/B2vuSrA7cM/8wlZsMYi/czkENRR05MO2rI6vhPT4YCVjbP8R8C0uhY80lw==", + "version": "13.2.9", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.9.tgz", + "integrity": "sha512-1+XfJNYF1cjGB+TKMWi29eZ0b82QOvQs2YoLNzbpWGqFMtRQHTa57osqdGj4FrFPgkO4D4AZinzUJR9VvW3QUA==", "dev": true, "requires": { - "chai": ">=1.9.2 <4.0.0", - "debug": "^2.2.0", - "deep-equal": "^1.0.0", + "debug": "^4.1.0", "json-stringify-safe": "^5.0.1", - "lodash": "^3.10.1", - "mkdirp": "^0.5.0", - "propagate": "0.3.x", - "qs": "^6.0.2" - }, - "dependencies": { - "chai": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", - "integrity": "sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==", - "dev": true, - "requires": { - "assertion-error": "^1.0.1", - "deep-eql": "^0.1.3", - "type-detect": "^1.0.0" - } - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "deep-eql": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "integrity": "sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==", - "dev": true, - "requires": { - "type-detect": "0.1.1" - }, - "dependencies": { - "type-detect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", - "integrity": "sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA==", - "dev": true - } - } - }, - "lodash": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", - "integrity": "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ==", - "dev": true - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "type-detect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", - "integrity": "sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA==", - "dev": true - } + "lodash": "^4.17.21", + "propagate": "^2.0.0" } }, "node-dir": { @@ -32683,16 +32579,6 @@ "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", "dev": true }, - "object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -33275,9 +33161,9 @@ } }, "propagate": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-0.3.1.tgz", - "integrity": "sha512-GOc8Eoa3MWbN905b5l2PNNt1Pf+I/CF6uAPt3IGT+v9WExDE7WPT/kDfLe7vYXtG11KbTnBhTNfcoq9+umDrSw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true }, "ps-tree": { diff --git a/packages/optimizely-sdk/package.json b/packages/optimizely-sdk/package.json index 028414316..f636bad51 100644 --- a/packages/optimizely-sdk/package.json +++ b/packages/optimizely-sdk/package.json @@ -53,6 +53,7 @@ "@types/chai": "^4.2.11", "@types/jest": "^23.3.12", "@types/mocha": "^5.2.7", + "@types/nise": "^1.4.0", "@types/uuid": "^3.4.4", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", @@ -71,7 +72,8 @@ "lodash": "^4.17.11", "mocha": "^5.2.0", "mocha-lcov-reporter": "^1.3.0", - "nock": "^7.7.2", + "nise": "^5.1.1", + "nock": "^13.2.9", "nyc": "^15.0.1", "promise-polyfill": "8.1.0", "rollup": "2.2.0", @@ -87,9 +89,9 @@ "webpack": "^5.74.0" }, "peerDependencies": { - "@react-native-community/netinfo": "5.9.4", + "@babel/runtime": "^7.0.0", "@react-native-async-storage/async-storage": "^1.2.0", - "@babel/runtime": "^7.0.0" + "@react-native-community/netinfo": "5.9.4" }, "peerDependenciesMeta": { "@react-native-async-storage/async-storage": { diff --git a/packages/optimizely-sdk/tests/browserRequestHandler.spec.ts b/packages/optimizely-sdk/tests/browserRequestHandler.spec.ts new file mode 100644 index 000000000..24624bebe --- /dev/null +++ b/packages/optimizely-sdk/tests/browserRequestHandler.spec.ts @@ -0,0 +1,144 @@ +/** + * Copyright 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// + +import { FakeXMLHttpRequest, FakeXMLHttpRequestStatic, fakeXhr } from 'nise'; +import { BrowserRequestHandler } from '../lib/utils/http_request_handler/browser_request_handler'; +import { NoOpLogger } from '../lib/plugins/logger'; + +describe('BrowserRequestHandler', () => { + const host = 'https://endpoint.example.com/api/query'; + const body = '{"foo":"bar"}'; + const dateString = 'Fri, 08 Mar 2019 18:57:18 GMT'; + + describe('makeRequest', () => { + let mockXHR: FakeXMLHttpRequestStatic; + let xhrs: FakeXMLHttpRequest[]; + let browserRequestHandler: BrowserRequestHandler; + + beforeEach(() => { + xhrs = []; + mockXHR = fakeXhr.useFakeXMLHttpRequest(); + mockXHR.onCreate = (request): number => xhrs.push(request); + browserRequestHandler = new BrowserRequestHandler(new NoOpLogger()); + }); + + afterEach(() => { + mockXHR.restore(); + }); + + it('should make a GET request to the argument URL', async () => { + const request = browserRequestHandler.makeRequest(host, {}, 'get'); + + expect(xhrs.length).toBe(1); + const xhr = xhrs[0]; + const { url, method } = xhr; + expect({ url, method }).toEqual({ + url: host, + method: 'get', + }); + xhr.respond(200, {}, body); + + const response = await request.responsePromise; + + expect(response.body).toEqual(body); + }); + + it('should return a 200 response', async () => { + const request = browserRequestHandler.makeRequest(host, {}, 'get'); + + const xhr = xhrs[0]; + xhr.respond(200, {}, body); + + const response = await request.responsePromise; + expect(response).toEqual({ + statusCode: 200, + headers: {}, + body, + }); + }); + + it('should return a 404 response', async () => { + const request = browserRequestHandler.makeRequest(host, {}, 'get'); + + const xhr = xhrs[0]; + xhr.respond(404, {}, ''); + + const response = await request.responsePromise; + expect(response).toEqual({ + statusCode: 404, + headers: {}, + body: '', + }); + }); + + it('should include headers from the headers argument in the request', async () => { + const request = browserRequestHandler.makeRequest(host, { + 'if-modified-since': dateString, + }, 'get'); + + expect(xhrs.length).toBe(1); + expect(xhrs[0].requestHeaders['if-modified-since']).toBe(dateString); + + xhrs[0].respond(404, {}, ''); + + await request.responsePromise; + }); + + it('should include headers from the response in the eventual response in the return value', async () => { + const request = browserRequestHandler.makeRequest(host, {}, 'get'); + const xhr = xhrs[0]; + xhr.respond( + 200, + { + 'content-type': 'application/json', + 'last-modified': dateString, + }, + body, + ); + + const response = await request.responsePromise; + + expect(response).toEqual({ + statusCode: 200, + body, + headers: { + 'content-type': 'application/json', + 'last-modified': dateString, + }, + }); + }); + + it('should return a rejected promise when there is a request error', async () => { + const request = browserRequestHandler.makeRequest(host, {}, 'get'); + xhrs[0].error(); + + await expect(request.responsePromise).rejects.toThrow(); + }); + + it('should set a timeout on the request object', () => { + const timeout = 60000; + const onCreateMock = jest.fn(); + mockXHR.onCreate = onCreateMock; + + new BrowserRequestHandler(new NoOpLogger(), timeout).makeRequest(host, {}, 'get'); + + expect(onCreateMock).toBeCalledTimes(1); + expect(onCreateMock.mock.calls[0][0].timeout).toBe(timeout); + }); + }); +}); diff --git a/packages/optimizely-sdk/tests/graphQLManager.spec.ts b/packages/optimizely-sdk/tests/graphQLManager.spec.ts new file mode 100644 index 000000000..4a877c1bc --- /dev/null +++ b/packages/optimizely-sdk/tests/graphQLManager.spec.ts @@ -0,0 +1,222 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// + +import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { IOdpClient, OdpClient } from '../lib/plugins/odp/odp_client'; +import { ErrorHandler, LogHandler, LogLevel } from '../lib/modules/logging'; +import { GraphqlManager } from '../lib/plugins/odp/graphql_manager'; +import { Response } from '../lib/plugins/odp/odp_types'; + +describe('GraphQLManager', () => { + const VALID_ODP_PUBLIC_KEY = 'not-real-api-key'; + const ODP_GRAPHQL_URL = 'https://some.example.com/graphql/endpoint'; + const FS_USER_ID = 'fs_user_id'; + const VALID_FS_USER_ID = 'tester-101'; + const SEGMENTS_TO_CHECK = [ + 'has_email', + 'has_email_opted_in', + 'push_on_sale', + ]; + + const makeManagerInstance = () => new GraphqlManager(instance(mockErrorHandler), instance(mockLogger), instance(mockOdpClient)); + + let mockErrorHandler: ErrorHandler; + let mockLogger: LogHandler; + let mockOdpClient: IOdpClient; + + beforeAll(() => { + mockErrorHandler = mock(); + mockLogger = mock(); + mockOdpClient = mock(); + }); + + beforeEach(() => { + resetCalls(mockErrorHandler); + resetCalls(mockLogger); + resetCalls(mockOdpClient); + }); + + it('should parse a successful response', () => { + const validJsonResponse = `{ + "data": { + "customer": { + "audiences": { + "edges": [ + { + "node": { + "name": "has_email", + "state": "qualified" + } + }, + { + "node": { + "name": "has_email_opted_in", + "state": "not-qualified" + } + } + ] + } + } + } + }`; + const manager = makeManagerInstance(); + + const response = manager['parseSegmentsResponseJson'](validJsonResponse) as Response; + + expect(response).not.toBeUndefined(); + expect(response?.errors).toHaveLength(0); + expect(response?.data?.customer?.audiences?.edges).not.toBeNull(); + expect(response.data.customer.audiences.edges).toHaveLength(2); + let node = response.data.customer.audiences.edges[0].node; + expect(node.name).toEqual('has_email'); + expect(node.state).toEqual('qualified'); + node = response.data.customer.audiences.edges[1].node; + expect(node.name).toEqual('has_email_opted_in'); + expect(node.state).not.toEqual('qualified'); + }); + + it('should parse an error response', () => { + const errorJsonResponse = `{ + "errors": [ + { + "message": "Exception while fetching data (/customer) : Exception: could not resolve _fs_user_id = asdsdaddddd", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "customer" + ], + "extensions": { + "classification": "InvalidIdentifierException" + } + } + ], + "data": { + "customer": null + } +}`; + const manager = makeManagerInstance(); + + const response = manager['parseSegmentsResponseJson'](errorJsonResponse) as Response; + + expect(response).not.toBeUndefined(); + expect(response.data.customer).toBeNull(); + expect(response?.errors).not.toBeNull(); + expect(response.errors[0].extensions.classification).toEqual('InvalidIdentifierException'); + }); + + it('should fetch valid qualified segments', async () => { + const responseJsonWithQualifiedSegments = '{"data":{"customer":{"audiences":' + + '{"edges":[{"node":{"name":"has_email",' + + '"state":"qualified"}},{"node":{"name":' + + '"has_email_opted_in","state":"qualified"}}]}}}}'; + when(mockOdpClient.querySegments(anything())).thenResolve(responseJsonWithQualifiedSegments); + const manager = makeManagerInstance(); + + const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(2); + expect(segments).toContain('has_email'); + expect(segments).toContain('has_email_opted_in'); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('should handle empty qualified segments', async () => { + const responseJsonWithNoQualifiedSegments = '{"data":{"customer":{"audiences":' + + '{"edges":[ ]}}}}'; + when(mockOdpClient.querySegments(anything())).thenResolve(responseJsonWithNoQualifiedSegments); + const manager = makeManagerInstance(); + + const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(0); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('should handle error with invalid identifier', async () => { + const INVALID_USER_ID = 'invalid-user'; + const errorJsonResponse = '{"errors":[{"message":' + + '"Exception while fetching data (/customer) : ' + + `Exception: could not resolve _fs_user_id = ${INVALID_USER_ID}",` + + '"locations":[{"line":1,"column":8}],"path":["customer"],' + + '"extensions":{"classification":"DataFetchingException"}}],' + + '"data":{"customer":null}}'; + when(mockOdpClient.querySegments(anything())).thenResolve(errorJsonResponse); + const manager = makeManagerInstance(); + const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, INVALID_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(0); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(anything(), anyString())).once(); + }); + + it('should handle unrecognized JSON responses', async () => { + const unrecognizedJson = '{"unExpectedObject":{ "withSome": "value", "thatIsNotParseable": "true" }}'; + when(mockOdpClient.querySegments(anything())).thenResolve(unrecognizedJson); + const manager = makeManagerInstance(); + + const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(0); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)')).once(); + }); + + it('should handle other exception types', async () => { + const errorJsonResponse = '{"errors":[{"message":"Validation error of type ' + + 'UnknownArgument: Unknown field argument not_real_userKey @ ' + + '\'customer\'","locations":[{"line":1,"column":17}],' + + '"extensions":{"classification":"ValidationError"}}]}'; + when(mockOdpClient.querySegments(anything())).thenResolve(errorJsonResponse); + const manager = makeManagerInstance(); + + const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(0); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(anything(), anyString())).once(); + }); + + it('should handle bad responses', async () => { + const badResponse = '{"data":{ }}'; + when(mockOdpClient.querySegments(anything())).thenResolve(badResponse); + const manager = makeManagerInstance(); + + const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(0); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)')).once(); + }); + + it('should handle non 200 HTTP status code response', async () => { + when(mockOdpClient.querySegments(anything())).thenResolve(null); + const manager = makeManagerInstance(); + + const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(0); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); + }); +}); + diff --git a/packages/optimizely-sdk/tests/nodeRequestHandler.spec.ts b/packages/optimizely-sdk/tests/nodeRequestHandler.spec.ts new file mode 100644 index 000000000..a5166bd1a --- /dev/null +++ b/packages/optimizely-sdk/tests/nodeRequestHandler.spec.ts @@ -0,0 +1,234 @@ +/** + * Copyright 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// + +import nock from 'nock'; +import zlib from 'zlib'; +import { NodeRequestHandler } from '../lib/utils/http_request_handler/node_request_handler'; +import { NoOpLogger } from '../lib/plugins/logger'; + +beforeAll(() => { + nock.disableNetConnect(); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +describe('NodeRequestHandler', () => { + const host = 'https://endpoint.example.com'; + const path = '/api/query'; + const body = '{"foo":"bar"}'; + + let nodeRequestHandler: NodeRequestHandler; + + beforeEach(() => { + nodeRequestHandler = new NodeRequestHandler(new NoOpLogger()); + }); + + afterEach(async () => { + nock.cleanAll(); + }); + + describe('makeRequest', () => { + it('should handle a 200 response back from a post', async () => { + const scope = nock(host) + .post(path) + .reply(200, body); + + const request = nodeRequestHandler.makeRequest(`${host}${path}`, {}, 'post', body); + const response = await request.responsePromise; + + expect(response).toEqual({ + statusCode: 200, + body, + headers: {}, + }); + scope.done(); + }); + + it('should handle a 400 response back ', async () => { + const scope = nock(host) + .post(path) + .reply(400, ''); + + const request = nodeRequestHandler.makeRequest(`${host}${path}`, {}, 'post'); + const response = await request.responsePromise; + + expect(response).toEqual({ + statusCode: 400, + body: '', + headers: {}, + }); + scope.done(); + }); + + it('should include headers from the headers argument in the request', async () => { + const scope = nock(host) + .matchHeader('if-modified-since', 'Fri, 08 Mar 2019 18:57:18 GMT') + .get(path) + .reply(304, ''); + const request = nodeRequestHandler.makeRequest(`${host}${path}`, { + 'if-modified-since': 'Fri, 08 Mar 2019 18:57:18 GMT', + }, 'get'); + const response = await request.responsePromise; + expect(response).toEqual({ + statusCode: 304, + body: '', + headers: {}, + }); + scope.done(); + }); + + it('should add Accept-Encoding request header and unzips a gzipped response body', async () => { + const scope = nock(host) + .matchHeader('accept-encoding', 'gzip,deflate') + .get(path) + .reply(200, () => zlib.gzipSync(body), { 'content-encoding': 'gzip' }); + + const request = nodeRequestHandler.makeRequest(`${host}${path}`, {}, 'get'); + const response = await request.responsePromise; + + expect(response).toMatchObject({ + statusCode: 200, + body: body, + }); + scope.done(); + }); + + it('should include headers from the response in the eventual response in the return value', async () => { + const scope = nock(host) + .get(path) + .reply( + 200, + JSON.parse(body), + { + 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', + }, + ); + + const request = nodeRequestHandler.makeRequest(`${host}${path}`, {}, 'get'); + const response = await request.responsePromise; + + expect(response).toEqual({ + statusCode: 200, + body, + headers: { + 'content-type': 'application/json', + 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', + }, + }); + scope.done(); + }); + + it('should handle a URL with a query string', async () => { + const pathWithQuery = '/datafiles/123.json?from_my_app=true'; + const scope = nock(host) + .get(pathWithQuery) + .reply(200, JSON.parse(body)); + + const request = nodeRequestHandler.makeRequest(`${host}${pathWithQuery}`, {}, 'get'); + await request.responsePromise; + + scope.done(); + }); + + it('should throw error for a URL with http protocol (not https)', async () => { + const invalidHttpProtocolUrl = 'http://some.example.com'; + + const request = nodeRequestHandler.makeRequest(invalidHttpProtocolUrl, {}, 'get'); + + await expect(request.responsePromise).rejects.toThrow(); + }); + + it('should returns a rejected response promise when the URL protocol is unsupported', async () => { + const invalidProtocolUrl = 'ftp://something/datafiles/123.json'; + + const request = nodeRequestHandler.makeRequest(invalidProtocolUrl, {}, 'get'); + + await expect(request.responsePromise).rejects.toThrow(); + }); + + it('should return a rejected promise when there is a request error', async () => { + const scope = nock(host) + .get(path) + .replyWithError({ + message: 'Connection error', + code: 'CONNECTION_ERROR', + }); + const request = nodeRequestHandler.makeRequest(`${host}${path}`, {}, 'get'); + await expect(request.responsePromise).rejects.toThrow(); + scope.done(); + }); + + it('should handle a url with a host and a port', async () => { + const hostWithPort = 'https://datafiles:44311'; + const path = '/12/345.json'; + const scope = nock(hostWithPort) + .get(path) + .reply(200, body); + + const request = nodeRequestHandler.makeRequest(`${hostWithPort}${path}`, {}, 'get'); + const response = await request.responsePromise; + + expect(response).toEqual({ + statusCode: 200, + body, + headers: {}, + }); + scope.done(); + }); + + describe('timeout', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should reject the response promise and abort the request when the response is not received before the timeout', async () => { + const scope = nock(host) + .get(path) + .delay({ head: 2000, body: 2000 }) + .reply(200, body); + + const abortEventListener = jest.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let emittedReq: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requestListener = (request: any): void => { + emittedReq = request; + emittedReq.once('timeout', abortEventListener); + }; + scope.on('request', requestListener); + + const request = new NodeRequestHandler(new NoOpLogger(), 100).makeRequest(`${host}${path}`, {}, 'get'); + + await expect(request.responsePromise).rejects.toThrow(); + expect(abortEventListener).toBeCalledTimes(1); + + scope.done(); + if (emittedReq) { + emittedReq.off('timeout', abortEventListener); + } + scope.off('request', requestListener); + }); + }); + }); +}); diff --git a/packages/optimizely-sdk/tests/odpClient.spec.ts b/packages/optimizely-sdk/tests/odpClient.spec.ts new file mode 100644 index 000000000..d0a7bbb57 --- /dev/null +++ b/packages/optimizely-sdk/tests/odpClient.spec.ts @@ -0,0 +1,243 @@ +/** + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// + +import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { ErrorHandler, LogHandler, LogLevel } from '../lib/modules/logging'; +import { OdpClient } from '../lib/plugins/odp/odp_client'; +import { QuerySegmentsParameters } from '../lib/plugins/odp/query_segments_parameters'; +import { BrowserRequestHandler } from '../lib/utils/http_request_handler/browser_request_handler'; +import { NodeRequestHandler } from '../lib/utils/http_request_handler/node_request_handler'; + +describe('OdpClient', () => { + const MOCK_QUERY_PARAMETERS = new QuerySegmentsParameters({ + apiKey: 'not-real-api-key', + apiHost: 'https://api.example.com/v3/graphql', + userKey: 'fs_user_id', + userValue: 'mock-user-id', + segmentsToCheck: [ + 'has_email', + 'has_email_opted_in', + 'push_on_sale', + ], + }); + const VALID_RESPONSE_JSON = { + 'data': { + 'customer': { + 'audiences': { + 'edges': [ + { + 'node': { + 'name': 'has_email', + 'state': 'qualified', + }, + }, + { + 'node': { + 'name': 'has_email_opted_in', + 'state': 'qualified', + }, + }, + ], + }, + }, + }, + }; + + let mockErrorHandler: ErrorHandler; + let mockLogger: LogHandler; + let mockBrowserRequestHandler: BrowserRequestHandler; + let mockNodeRequestHandler: NodeRequestHandler; + + beforeAll(() => { + mockErrorHandler = mock(); + mockLogger = mock(); + mockBrowserRequestHandler = mock(); + mockNodeRequestHandler = mock(); + }); + + beforeEach(() => { + resetCalls(mockErrorHandler); + resetCalls(mockLogger); + resetCalls(mockBrowserRequestHandler); + resetCalls(mockNodeRequestHandler); + }); + + it('should handle missing API Host', async () => { + const missingApiHost = new QuerySegmentsParameters({ + apiKey: 'apiKey', + apiHost: '', + userKey: 'userKey', + userValue: 'userValue', + segmentsToCheck: ['segmentToCheck'], + }); + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); + + await client.querySegments(missingApiHost); + + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(LogLevel.ERROR, 'No ApiHost or ApiKey set before querying segments')).once(); + }); + + it('should handle missing API Key', async () => { + const missingApiHost = new QuerySegmentsParameters({ + apiKey: '', + apiHost: 'apiHost', + userKey: 'userKey', + userValue: 'userValue', + segmentsToCheck: ['segmentToCheck'], + }); + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); + + await client.querySegments(missingApiHost); + + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(LogLevel.ERROR, 'No ApiHost or ApiKey set before querying segments')).once(); + }); + + it('Browser: should get mocked segments successfully', async () => { + when(mockBrowserRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.resolve({ + statusCode: 200, + body: JSON.stringify(VALID_RESPONSE_JSON), + headers: {}, + }), + }); + + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockBrowserRequestHandler)); + + const response = await client.querySegments(MOCK_QUERY_PARAMETERS) ?? ''; + + expect(response).toEqual(JSON.stringify(VALID_RESPONSE_JSON)); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('Node: should get mocked segments successfully', async () => { + when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.resolve({ + statusCode: 200, + body: JSON.stringify(VALID_RESPONSE_JSON), + headers: {}, + }), + }); + + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); + + const response = await client.querySegments(MOCK_QUERY_PARAMETERS) ?? ''; + + expect(response).toEqual(JSON.stringify(VALID_RESPONSE_JSON)); + verify(mockErrorHandler.handleError(anything())).never(); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('Browser should handle 400 HTTP response', async () => { + when(mockBrowserRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.reject({ + statusCode: 400, + body: '', + headers: {}, + }), + }); + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockBrowserRequestHandler)); + + const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); + + expect(responseJson).toBeNull(); + verify(mockErrorHandler.handleError(anything())).once(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (400)')).once(); + }); + + it('Node should handle 400 HTTP response', async () => { + when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.reject({ + statusCode: 400, + body: '', + headers: {}, + }), + }); + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); + + const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); + + expect(responseJson).toBeNull(); + verify(mockErrorHandler.handleError(anything())).once(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (400)')).once(); + }); + + it('Browser should handle 500 HTTP response', async () => { + when(mockBrowserRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.reject({ + statusCode: 500, + body: '', + headers: {}, + }), + }); + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockBrowserRequestHandler)); + + const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); + + expect(responseJson).toBeNull(); + verify(mockErrorHandler.handleError(anything())).once(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (500)')).once(); + }); + + it('Node should handle 500 HTTP response', async () => { + when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.reject({ + statusCode: 500, + body: '', + headers: {}, + }), + }); + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); + + const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); + + expect(responseJson).toBeNull(); + verify(mockErrorHandler.handleError(anything())).once(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (500)')).once(); + }); + + it('should handle a network timeout', async () => { + when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.reject(new Error('Request timed out')), + }); + const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler), 10); + + const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); + + expect(responseJson).toBeNull(); + verify(mockErrorHandler.handleError(anything())).once(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); + }); +}); +