diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_config.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_config.ts new file mode 100644 index 000000000..215d3655c --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/odp_config.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +export class OdpConfig { + /** + * Host of ODP audience segments API. + * @private + */ + private _apiHost: string; + + /** + * Getter to retrieve the ODP server host + * @public + */ + public get apiHost(): string { + return this._apiHost; + } + + /** + * Public API key for the ODP account from which the audience segments will be fetched (optional). + * @private + */ + private _apiKey: string; + + /** + * Getter to retrieve the ODP API key + * @public + */ + public get apiKey(): string { + return this._apiKey; + } + + /** + * All ODP segments used in the current datafile (associated with apiHost/apiKey). + * @private + */ + private _segmentsToCheck: string[]; + + /** + * Getter for ODP segments to check + * @public + */ + public get segmentsToCheck(): string[] { + return this._segmentsToCheck; + } + + constructor(apiKey: string, apiHost: string, segmentsToCheck?: string[]) { + this._apiKey = apiKey; + this._apiHost = apiHost; + this._segmentsToCheck = segmentsToCheck ?? []; + } + + /** + * Update the ODP configuration details + * @param apiKey Public API key for the ODP account + * @param apiHost Host of ODP audience segments API + * @param segmentsToCheck Audience segments + * @returns true if configuration was updated successfully + */ + public update(apiKey: string, apiHost: string, segmentsToCheck: string[]): boolean { + if (this._apiKey === apiKey && this._apiHost === apiHost && this._segmentsToCheck === segmentsToCheck) { + return false; + } else { + this._apiKey = apiKey; + this._apiHost = apiHost; + this._segmentsToCheck = segmentsToCheck; + + return true; + } + } + + /** + * Determines if ODP configuration has the minimum amount of information + */ + public isReady(): boolean { + return !!this._apiKey && !!this._apiHost; + } +} diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_event_manager.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_event_manager.ts new file mode 100644 index 000000000..766d5fa0e --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/odp_event_manager.ts @@ -0,0 +1,406 @@ +/** + * 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, LogLevel } from '../../modules/logging'; +import { OdpEvent } from './odp_event'; +import { uuid } from '../../utils/fns'; +import { ODP_USER_KEY } from '../../utils/enums'; +import { OdpConfig } from './odp_config'; +import { RestApiManager } from './rest_api_manager'; + +const MAX_RETRIES = 3; +const DEFAULT_BATCH_SIZE = 10; +const DEFAULT_FLUSH_INTERVAL_MSECS = 1000; +const DEFAULT_BROWSER_QUEUE_SIZE = 100; +const DEFAULT_SERVER_QUEUE_SIZE = 10000; + +/** + * Event dispatcher's execution states + */ +export enum STATE { + STOPPED, + RUNNING, + PROCESSING, +} + +/** + * Manager for persisting events to the Optimizely Data Platform (ODP) + */ +export interface IOdpEventManager { + updateSettings(odpConfig: OdpConfig): void; + + start(): void; + + stop(): Promise; + + registerVuid(vuid: string): void; + + identifyUser(userId: string, vuid?: string): void; + + sendEvent(event: OdpEvent): void; +} + +/** + * Concrete implementation of a manager for persisting events to the Optimizely Data Platform + */ +export class OdpEventManager implements IOdpEventManager { + /** + * Current state of the event processor + */ + public state: STATE = STATE.STOPPED; + /** + * Queue for holding all events to be eventually dispatched + * @private + */ + private queue = new Array(); + /** + * Identifier of the currently running timeout so clearCurrentTimeout() can be called + * @private + */ + private timeoutId?: NodeJS.Timeout | number; + /** + * ODP configuration settings in used + * @private + */ + private odpConfig: OdpConfig; + /** + * REST API Manager used to send the events + * @private + */ + private readonly apiManager: RestApiManager; + /** + * Handler for recording execution logs + * @private + */ + private readonly logger: LogHandler; + /** + * Maximum queue size + * @private + */ + private readonly queueSize: number; + /** + * Maximum number of events to process at once + * @private + */ + private readonly batchSize: number; + /** + * Milliseconds between setTimeout() to process new batches + * @private + */ + private readonly flushInterval: number; + /** + * Type of execution context eg node, js, react + * @private + */ + private readonly clientEngine: string; + /** + * Version of the client being used + * @private + */ + private readonly clientVersion: string; + + public constructor({ + odpConfig, + apiManager, + logger, + clientEngine, + clientVersion, + queueSize, + batchSize, + flushInterval, + }: { + odpConfig: OdpConfig, + apiManager: RestApiManager, + logger: LogHandler, + clientEngine: string, + clientVersion: string, + queueSize?: number, + batchSize?: number, + flushInterval?: number + }) { + this.odpConfig = odpConfig; + this.apiManager = apiManager; + this.logger = logger; + this.clientEngine = clientEngine; + this.clientVersion = clientVersion; + + this.queueSize = queueSize || (process ? DEFAULT_SERVER_QUEUE_SIZE : DEFAULT_BROWSER_QUEUE_SIZE); + this.batchSize = batchSize || DEFAULT_BATCH_SIZE; + this.flushInterval = flushInterval || DEFAULT_FLUSH_INTERVAL_MSECS; + + this.state = STATE.STOPPED; + } + + /** + * Update ODP configuration settings + * @param odpConfig New configuration to apply + */ + public updateSettings(odpConfig: OdpConfig): void { + this.odpConfig = odpConfig; + } + + /** + * Start processing events in the queue + */ + public start(): void { + this.state = STATE.RUNNING; + + this.setNewTimeout(); + } + + /** + * Drain the queue sending all remaining events in batches then stop processing + */ + public async stop(): Promise { + this.logger.log(LogLevel.DEBUG, 'Stop requested.'); + + await this.processQueue(true); + + this.state = STATE.STOPPED; + this.logger.log(LogLevel.DEBUG, 'Stopped. Queue Count: %s', this.queue.length); + } + + /** + * Register a new visitor user id (VUID) in ODP + * @param vuid Visitor User ID to send + */ + public registerVuid(vuid: string): void { + const identifiers = new Map(); + identifiers.set(ODP_USER_KEY.VUID, vuid); + + const event = new OdpEvent('fullstack', 'client_initialized', identifiers); + this.sendEvent(event); + } + + /** + * Associate a full-stack userid with an established VUID + * @param userId Full-stack User ID + * @param vuid Visitor User ID + */ + public identifyUser(userId: string, vuid?: string): void { + const identifiers = new Map(); + if (vuid) { + identifiers.set(ODP_USER_KEY.VUID, vuid); + } + identifiers.set(ODP_USER_KEY.FS_USER_ID, userId); + + const event = new OdpEvent('fullstack', 'identified', identifiers); + this.sendEvent(event); + } + + /** + * Send an event to ODP via dispatch queue + * @param event ODP Event to forward + */ + public sendEvent(event: OdpEvent): void { + if (this.invalidDataFound(event.data)) { + this.logger.log(LogLevel.ERROR, 'Event data found to be invalid.'); + } else { + event.data = this.augmentCommonData(event.data); + this.enqueue(event); + } + } + + /** + * Add a new event to the main queue + * @param event ODP Event to be queued + * @private + */ + private enqueue(event: OdpEvent): void { + if (this.state === STATE.STOPPED) { + this.logger.log(LogLevel.WARNING, 'Failed to Process ODP Event. ODPEventManager is not running.'); + return; + } + + if (!this.odpConfig.isReady()) { + this.logger.log(LogLevel.DEBUG, 'Unable to Process ODP Event. ODPConfig is not ready.'); + return; + } + + if (this.queue.length >= this.queueSize) { + this.logger.log(LogLevel.WARNING, 'Failed to Process ODP Event. Event Queue full. queueSize = %s.', this.queue.length); + return; + } + + this.queue.push(event); + + this.processQueue(); + } + + /** + * Process events in the main queue + * @param shouldFlush Flush all events regardless of available queue event count + * @private + */ + private processQueue(shouldFlush = false): void { + if (this.state !== STATE.RUNNING) { + return; + } + + if (!this.isOdpConfigurationReady()) { + return; + } + + // Flush interval occurred & queue has items + if (shouldFlush) { + // clear the queue completely + this.clearCurrentTimeout(); + + this.state = STATE.PROCESSING; + + while (this.queueContainsItems()) { + this.makeAndSend1Batch(); + } + } + // Check if queue has a full batch available + else if (this.queueHasBatches()) { + this.clearCurrentTimeout(); + + this.state = STATE.PROCESSING; + + while (this.queueHasBatches()) { + this.makeAndSend1Batch(); + } + } + + this.state = STATE.RUNNING; + this.setNewTimeout(); + } + + /** + * Clear the currently running timout + * @private + */ + private clearCurrentTimeout(): void { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + /** + * Start a new timeout + * @private + */ + private setNewTimeout(): void { + if (this.timeoutId !== undefined) { + return; + } + this.timeoutId = setTimeout(() => this.processQueue(true), this.flushInterval); + } + + /** + * Make a batch and send it to ODP + * @private + */ + private makeAndSend1Batch(): void { + const batch = new Array(); + + // remove a batch from the queue + for (let count = 0; count < this.batchSize; count += 1) { + const event = this.queue.shift(); + if (event) { + batch.push(event); + } else { + break; + } + } + + if (batch.length > 0) { + // put sending the event on another event loop + setTimeout(async () => { + let shouldRetry: boolean; + let attemptNumber = 0; + do { + shouldRetry = await this.apiManager.sendEvents(this.odpConfig.apiKey, this.odpConfig.apiHost, batch); + attemptNumber += 1; + } while (shouldRetry && attemptNumber < MAX_RETRIES); + }); + } + } + + /** + * Check if main queue has any full/even batches available + * @returns True if there are event batches available in the queue otherwise False + * @private + */ + private queueHasBatches(): boolean { + return this.queueContainsItems() && this.queue.length % this.batchSize === 0; + } + + /** + * Check if main queue has any items + * @returns True if there are any events in the queue otherwise False + * @private + */ + private queueContainsItems(): boolean { + return this.queue.length > 0; + } + + /** + * Check if the ODP Configuration is ready and log if not. + * Potentially clear queue if server-side + * @returns True if the ODP configuration is ready otherwise False + * @private + */ + private isOdpConfigurationReady(): boolean { + if (this.odpConfig.isReady()) { + return true; + } + + if (process) { + // if Node/server-side context, empty queue items before ready state + this.logger.log(LogLevel.WARNING, 'ODPConfig not ready. Discarding events in queue.'); + this.queue = new Array(); + } else { + // in Browser/client-side context, give debug message but leave events in queue + this.logger.log(LogLevel.DEBUG, 'ODPConfig not ready. Leaving events in queue.'); + } + return false; + } + + /** + * Validate event data value types + * @param data Event data to be validated + * @returns True if an invalid type was found in the data otherwise False + * @private + */ + private invalidDataFound(data: Map): boolean { + const validTypes: string[] = ['string', 'number', 'boolean']; + let foundInvalidValue = false; + data.forEach((value) => { + if (!validTypes.includes(typeof value) && value !== null) { + foundInvalidValue = true; + } + }); + return foundInvalidValue; + } + + /** + * Add additional common data including an idempotent ID and execution context to event data + * @param sourceData Existing event data to augment + * @returns Augmented event data + * @private + */ + private augmentCommonData(sourceData: Map): Map { + const data = new Map(); + data.set('idempotence_id', uuid()); + data.set('data_source_type', 'sdk'); + data.set('data_source', this.clientEngine); + data.set('data_source_version', this.clientVersion); + + sourceData.forEach((value, key) => data.set(key, value)); + return data; + } +} diff --git a/packages/optimizely-sdk/lib/plugins/odp/rest_api_manager.ts b/packages/optimizely-sdk/lib/plugins/odp/rest_api_manager.ts index de872f3cd..8a58de202 100644 --- a/packages/optimizely-sdk/lib/plugins/odp/rest_api_manager.ts +++ b/packages/optimizely-sdk/lib/plugins/odp/rest_api_manager.ts @@ -65,7 +65,7 @@ export class RestApiManager implements IRestApiManager { } const endpoint = `${apiHost}/v3/events`; - const data = JSON.stringify(events); + const data = JSON.stringify(events, this.replacer); const method = 'POST'; const headers = { @@ -97,4 +97,12 @@ export class RestApiManager implements IRestApiManager { return shouldRetry; } + + private replacer(_: unknown, value: unknown) { + if (value instanceof Map) { + return Object.fromEntries(value); + } else { + return value; + } + } } diff --git a/packages/optimizely-sdk/lib/utils/enums/index.ts b/packages/optimizely-sdk/lib/utils/enums/index.ts index 38ea49a0c..d00e65b66 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.ts +++ b/packages/optimizely-sdk/lib/utils/enums/index.ts @@ -304,12 +304,3 @@ export enum ODP_USER_KEY { VUID = 'vuid', FS_USER_ID = 'fs_user_id', } - -/** - * Possible states of ODP integration - */ -export enum ODP_CONFIG_STATE { - UNDETERMINED = 0, - INTEGRATED, - NOT_INTEGRATED = 2, -} diff --git a/packages/optimizely-sdk/tests/odpEventManager.spec.ts b/packages/optimizely-sdk/tests/odpEventManager.spec.ts new file mode 100644 index 000000000..12ee14a84 --- /dev/null +++ b/packages/optimizely-sdk/tests/odpEventManager.spec.ts @@ -0,0 +1,379 @@ +/** + * 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 { OdpConfig } from '../lib/plugins/odp/odp_config'; +import { OdpEventManager, STATE } from '../lib/plugins/odp/odp_event_manager'; +import { anything, capture, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; +import { RestApiManager } from '../lib/plugins/odp/rest_api_manager'; +import { LogHandler, LogLevel } from '../lib/modules/logging'; +import { OdpEvent } from '../lib/plugins/odp/odp_event'; +import { RequestHandler } from '../lib/utils/http_request_handler/http'; + +const API_KEY = 'test-api-key'; +const API_HOST = 'https://odp.example.com'; +const MOCK_IDEMPOTENCE_ID = 'c1dc758e-f095-4f09-9b49-172d74c53880'; +const EVENTS: OdpEvent[] = [ + new OdpEvent( + 't1', + 'a1', + new Map([['id-key-1', 'id-value-1']]), + new Map(Object.entries({ + 'key-1': 'value1', + 'key-2': null, + 'key-3': 3.3, + 'key-4': true, + })), + ), + new OdpEvent( + 't2', + 'a2', + new Map([['id-key-2', 'id-value-2']]), + new Map(Object.entries({ + 'key-2': 'value2', + 'data_source': 'my-source', + })), + ), +]; +// naming for object destructuring +const clientEngine = 'javascript-sdk'; +const clientVersion = '4.9.2'; +const PROCESSED_EVENTS: OdpEvent[] = [ + new OdpEvent( + 't1', + 'a1', + new Map([['id-key-1', 'id-value-1']]), + new Map(Object.entries({ + 'idempotence_id': MOCK_IDEMPOTENCE_ID, + 'data_source_type': 'sdk', + 'data_source': clientEngine, + 'data_source_version': clientVersion, + 'key-1': 'value1', + 'key-2': null, + 'key-3': 3.3, + 'key-4': true, + })), + ), + new OdpEvent( + 't2', + 'a2', + new Map([['id-key-2', 'id-value-2']]), + new Map(Object.entries({ + 'idempotence_id': MOCK_IDEMPOTENCE_ID, + 'data_source_type': 'sdk', + 'data_source': clientEngine, + 'data_source_version': clientVersion, + 'key-2': 'value2', + })), + ), +]; +const makeEvent = (id: number) => { + const identifiers = new Map(); + identifiers.set('identifier1', 'value1-' + id); + identifiers.set('identifier2', 'value2-' + id); + + const data = new Map(); + data.set('data1', 'data-value1-' + id); + data.set('data2', id); + + return new OdpEvent('test-type-' + id, 'test-action-' + id, identifiers, data); +}; +const pause = (timeoutMilliseconds: number): Promise => { + return new Promise(resolve => setTimeout(resolve, timeoutMilliseconds)); +}; +const abortableRequest = (statusCode: number, body: string) => { + return { + abort: () => { + }, + responsePromise: Promise.resolve({ + statusCode, + body, + headers: {}, + }), + }; +}; + +describe('OdpEventManager', () => { + let mockLogger: LogHandler; + let mockApiManager: RestApiManager; + + let odpConfig: OdpConfig; + let logger: LogHandler; + let apiManager: RestApiManager; + + beforeAll(() => { + mockLogger = mock(); + mockApiManager = mock(); + + odpConfig = new OdpConfig(API_KEY, API_HOST, []); + logger = instance(mockLogger); + apiManager = instance(mockApiManager); + }); + + beforeEach(() => { + resetCalls(mockLogger); + resetCalls(mockApiManager); + }); + + it('should log and discard events when event manager not running', () => { + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + }); + // since we've not called start() then... + + eventManager.sendEvent(EVENTS[0]); + + // ...we should get a notice after trying to send an event + verify(mockLogger.log(LogLevel.WARNING, 'Failed to Process ODP Event. ODPEventManager is not running.')).once(); + }); + + it('should log and discard events when event manager config is not ready', () => { + const mockOdpConfig = mock(); + when(mockOdpConfig.isReady()).thenReturn(false); + const odpConfig = instance(mockOdpConfig); + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + }); + eventManager['state'] = STATE.RUNNING; // simulate running without calling start() + + eventManager.sendEvent(EVENTS[0]); + + verify(mockLogger.log(LogLevel.DEBUG, 'Unable to Process ODP Event. ODPConfig is not ready.')).once(); + }); + + it('should discard events with invalid data', () => { + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + }); + // make an event with invalid data key-value entry + const badEvent = new OdpEvent( + 't3', + 'a3', + new Map([['id-key-3', 'id-value-3']]), + new Map(Object.entries({ + 'key-1': false, + 'key-2': { random: 'object', whichShouldFail: true }, + })), + ); + eventManager.sendEvent(badEvent); + + verify(mockLogger.log(LogLevel.ERROR, 'Event data found to be invalid.')).once(); + }); + + it('should log a max queue hit and discard ', () => { + // set queue to maximum of 1 + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, queueSize: 1, // With max queue size set to 1... + }); + eventManager['state'] = STATE.RUNNING; + eventManager['queue'].push(EVENTS[0]); // simulate 1 event already in the queue then... + + // ...try adding the second event + eventManager.sendEvent(EVENTS[1]); + + verify(mockLogger.log(LogLevel.WARNING, 'Failed to Process ODP Event. Event Queue full. queueSize = %s.', 1)).once(); + }); + + it('should add additional information to each event', () => { + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + }); + const processedEventData = PROCESSED_EVENTS[0].data; + + const eventData = eventManager['augmentCommonData'](EVENTS[0].data); + + expect((eventData.get('idempotence_id') as string).length).toEqual((processedEventData.get('idempotence_id') as string).length); + expect(eventData.get('data_source_type')).toEqual(processedEventData.get('data_source_type')); + expect(eventData.get('data_source')).toEqual(processedEventData.get('data_source')); + expect(eventData.get('data_source_version')).toEqual(processedEventData.get('data_source_version')); + expect(eventData.get('key-1')).toEqual(processedEventData.get('key-1')); + expect(eventData.get('key-2')).toEqual(processedEventData.get('key-2')); + expect(eventData.get('key-3')).toEqual(processedEventData.get('key-3')); + expect(eventData.get('key-4')).toEqual(processedEventData.get('key-4')); + }); + + it('should attempt to flush an empty queue at flush intervals', async () => { + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + flushInterval: 100, + }); + const spiedEventManager = spy(eventManager); + + eventManager.start(); + // do not add events to the queue, but allow for... + await pause(400); // at least 3 flush intervals executions (giving a little longer) + + verify(spiedEventManager['processQueue'](anything())).atLeast(3); + }); + + it('should dispatch events in correct number of batches', async () => { + when(mockApiManager.sendEvents(anything(), anything(), anything())).thenResolve(false); + const apiManager = instance(mockApiManager); + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + batchSize: 10, // with batch size of 10... + flushInterval: 250, + }); + + eventManager.start(); + for (let i = 0; i < 25; i += 1) { + eventManager.sendEvent(makeEvent(i)); + } + await pause(1500); + + // ...there should be 3 batches: + // batch #1 with 10, batch #2 with 10, and batch #3 (after flushInterval lapsed) with 5 = 25 events + verify(mockApiManager.sendEvents(anything(), anything(), anything())).thrice(); + }); + + it('should dispatch events with correct payload', async () => { + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, batchSize: 10, flushInterval: 100, + }); + + eventManager.start(); + EVENTS.forEach(event => eventManager.sendEvent(event)); + await pause(1000); + + // sending 1 batch of 2 events after flushInterval since batchSize is 10 + verify(mockApiManager.sendEvents(anything(), anything(), anything())).once(); + const [apiKey, apiHost, events] = capture(mockApiManager.sendEvents).last(); + expect(apiKey).toEqual(API_KEY); + expect(apiHost).toEqual(API_HOST); + expect(events.length).toEqual(2); + expect(events[0].identifiers.size).toEqual(PROCESSED_EVENTS[0].identifiers.size); + expect(events[0].data.size).toEqual(PROCESSED_EVENTS[0].data.size); + expect(events[1].identifiers.size).toEqual(PROCESSED_EVENTS[1].identifiers.size); + expect(events[1].data.size).toEqual(PROCESSED_EVENTS[1].data.size); + }); + + it('should retry failed events', async () => { + // all events should fail ie shouldRetry = true + when(mockApiManager.sendEvents(anything(), anything(), anything())).thenResolve(true); + const apiManager = instance(mockApiManager); + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + batchSize: 2, // batch size of 2 + flushInterval: 100, + }); + + eventManager.start(); + // send 4 events + for (let i = 0; i < 4; i += 1) { + eventManager.sendEvent(makeEvent(i)); + } + await pause(1500); + + // retry 3x (default) for 2 batches or 6 calls to attempt to process + verify(mockApiManager.sendEvents(anything(), anything(), anything())).times(6); + }); + + it('should flush all scheduled events before stopping', async () => { + when(mockApiManager.sendEvents(anything(), anything(), anything())).thenResolve(false); + const apiManager = instance(mockApiManager); + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + batchSize: 2, // batches of 2 with... + flushInterval: 100, + }); + + eventManager.start(); + // ...25 events should... + for (let i = 0; i < 25; i += 1) { + eventManager.sendEvent(makeEvent(i)); + } + await pause(300); + await eventManager.stop(); + + verify(mockLogger.log(LogLevel.DEBUG, 'Stop requested.')).once(); + verify(mockLogger.log(LogLevel.DEBUG, 'Stopped. Queue Count: %s', 0)).once(); + }); + + it('should prepare correct payload for register VUID', async () => { + const mockRequestHandler: RequestHandler = mock(); + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, '')); + const apiManager = new RestApiManager(instance(mockRequestHandler), logger); + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, batchSize: 10, flushInterval: 100, + }); + const vuid = 'vuid_330e05cad15746d9af8a75b8d10'; + + eventManager.start(); + eventManager.registerVuid(vuid); + await pause(1500); + + const [requestUrl, headers, method, data] = capture(mockRequestHandler.makeRequest).last(); + expect(requestUrl).toEqual(`${API_HOST}/v3/events`); + expect(headers['Content-Type']).toEqual('application/json'); + expect(headers['x-api-key']).toEqual('test-api-key'); + expect(method).toEqual('POST'); + const events = JSON.parse(data as string); + const event = events[0]; + expect(event.type).toEqual('fullstack'); + expect(event.action).toEqual('client_initialized'); + expect(event.identifiers).toEqual({ 'vuid': vuid }); + expect(event.data.idempotence_id.length).toBe(36); // uuid length + expect(event.data.data_source_type).toEqual('sdk'); + expect(event.data.data_source).toEqual('javascript-sdk'); + expect(event.data.data_source_version).not.toBeNull(); + }); + + it('should prepare correct payload for identify user', async () => { + const mockRequestHandler: RequestHandler = mock(); + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, '')); + const apiManager = new RestApiManager(instance(mockRequestHandler), logger); + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, flushInterval: 100, + }); + const vuid = 'vuid_330e05cad15746d9af8a75b8d10'; + const fsUserId = 'test-fs-user-id'; + + eventManager.start(); + eventManager.identifyUser(fsUserId, vuid); + await pause(1500); + + const [requestUrl, headers, method, data] = capture(mockRequestHandler.makeRequest).last(); + expect(requestUrl).toEqual(`${API_HOST}/v3/events`); + expect(headers['Content-Type']).toEqual('application/json'); + expect(headers['x-api-key']).toEqual('test-api-key'); + expect(method).toEqual('POST'); + const events = JSON.parse(data as string); + const event = events[0]; + expect(event.type).toEqual('fullstack'); + expect(event.action).toEqual('identified'); + expect(event.identifiers).toEqual({ 'vuid': vuid, 'fs_user_id': fsUserId }); + expect(event.data.idempotence_id.length).toBe(36); // uuid length + expect(event.data.data_source_type).toEqual('sdk'); + expect(event.data.data_source).toEqual('javascript-sdk'); + expect(event.data.data_source_version).not.toBeNull(); + }); + + it('should apply updated ODP configuration when available', () => { + const eventManager = new OdpEventManager({ + odpConfig, apiManager, logger, clientEngine, clientVersion, + }); + const apiKey = 'testing-api-key'; + const apiHost = 'https://some.other.example.com'; + const segmentsToCheck = ['empty-cart', '1-item-cart']; + const differentOdpConfig = new OdpConfig(apiKey, apiHost, segmentsToCheck); + + eventManager.updateSettings(differentOdpConfig); + + expect(eventManager['odpConfig'].apiKey).toEqual(apiKey); + expect(eventManager['odpConfig'].apiHost).toEqual(apiHost); + expect(eventManager['odpConfig'].segmentsToCheck).toContain(segmentsToCheck[0]); + expect(eventManager['odpConfig'].segmentsToCheck).toContain(segmentsToCheck[1]); + }); +}); diff --git a/packages/optimizely-sdk/tsconfig.spec.json b/packages/optimizely-sdk/tsconfig.spec.json new file mode 100644 index 000000000..877e2b462 --- /dev/null +++ b/packages/optimizely-sdk/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "jest" + ], + "typeRoots": [ + "./node_modules/@types" + ] + }, + "include": [ + "**/*.spec.ts" + ] +}