diff --git a/lib/export_types.ts b/lib/export_types.ts index df11a89a8..a55f56f27 100644 --- a/lib/export_types.ts +++ b/lib/export_types.ts @@ -45,5 +45,4 @@ export { TrackListenerPayload, NotificationCenter, OptimizelySegmentOption, - ICache, } from './shared_types'; diff --git a/lib/index.browser.tests.js b/lib/index.browser.tests.js index 15145c7a6..0a7859353 100644 --- a/lib/index.browser.tests.js +++ b/lib/index.browser.tests.js @@ -23,14 +23,6 @@ import testData from './tests/test_data'; import packageJSON from '../package.json'; import optimizelyFactory from './index.browser'; import configValidator from './utils/config_validator'; -import OptimizelyUserContext from './optimizely_user_context'; - -import { LOG_MESSAGES, ODP_EVENT_ACTION } from './utils/enums'; -import { BrowserOdpManager } from './odp/odp_manager.browser'; -import { OdpConfig } from './odp/odp_config'; -import { BrowserOdpEventManager } from './odp/event_manager/event_manager.browser'; -import { BrowserOdpEventApiManager } from './odp/event_manager/event_api_manager.browser'; -import { OdpEvent } from './odp/event_manager/odp_event'; import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager'; import { createProjectConfig } from './project_config/project_config'; @@ -432,152 +424,6 @@ describe('javascript-sdk (Browser)', function() { sinon.assert.calledWithExactly(logging.setLogHandler, fakeLogger); }); }); - - // TODO: user will create and inject an event processor - // these tests will be refactored accordingly - // describe('event processor configuration', function() { - // beforeEach(function() { - // sinon.stub(eventProcessor, 'createEventProcessor'); - // }); - - // afterEach(function() { - // eventProcessor.createEventProcessor.restore(); - // }); - - // it('should use default event flush interval when none is provided', function() { - // optimizelyFactory.createInstance({ - // datafile: testData.getTestProjectConfigWithFeatures(), - // errorHandler: fakeErrorHandler, - // eventDispatcher: fakeEventDispatcher, - // logger: silentLogger, - // }); - // sinon.assert.calledWithExactly( - // eventProcessor.createEventProcessor, - // sinon.match({ - // flushInterval: 1000, - // }) - // ); - // }); - - // describe('with an invalid flush interval', function() { - // beforeEach(function() { - // sinon.stub(eventProcessorConfigValidator, 'validateEventFlushInterval').returns(false); - // }); - - // afterEach(function() { - // eventProcessorConfigValidator.validateEventFlushInterval.restore(); - // }); - - // it('should ignore the event flush interval and use the default instead', function() { - // optimizelyFactory.createInstance({ - // datafile: testData.getTestProjectConfigWithFeatures(), - // errorHandler: fakeErrorHandler, - // eventDispatcher: fakeEventDispatcher, - // logger: silentLogger, - // eventFlushInterval: ['invalid', 'flush', 'interval'], - // }); - // sinon.assert.calledWithExactly( - // eventProcessor.createEventProcessor, - // sinon.match({ - // flushInterval: 1000, - // }) - // ); - // }); - // }); - - // describe('with a valid flush interval', function() { - // beforeEach(function() { - // sinon.stub(eventProcessorConfigValidator, 'validateEventFlushInterval').returns(true); - // }); - - // afterEach(function() { - // eventProcessorConfigValidator.validateEventFlushInterval.restore(); - // }); - - // it('should use the provided event flush interval', function() { - // optimizelyFactory.createInstance({ - // datafile: testData.getTestProjectConfigWithFeatures(), - // errorHandler: fakeErrorHandler, - // eventDispatcher: fakeEventDispatcher, - // logger: silentLogger, - // eventFlushInterval: 9000, - // }); - // sinon.assert.calledWithExactly( - // eventProcessor.createEventProcessor, - // sinon.match({ - // flushInterval: 9000, - // }) - // ); - // }); - // }); - - // it('should use default event batch size when none is provided', function() { - // optimizelyFactory.createInstance({ - // datafile: testData.getTestProjectConfigWithFeatures(), - // errorHandler: fakeErrorHandler, - // eventDispatcher: fakeEventDispatcher, - // logger: silentLogger, - // }); - // sinon.assert.calledWithExactly( - // eventProcessor.createEventProcessor, - // sinon.match({ - // batchSize: 10, - // }) - // ); - // }); - - // describe('with an invalid event batch size', function() { - // beforeEach(function() { - // sinon.stub(eventProcessorConfigValidator, 'validateEventBatchSize').returns(false); - // }); - - // afterEach(function() { - // eventProcessorConfigValidator.validateEventBatchSize.restore(); - // }); - - // it('should ignore the event batch size and use the default instead', function() { - // optimizelyFactory.createInstance({ - // datafile: testData.getTestProjectConfigWithFeatures(), - // errorHandler: fakeErrorHandler, - // eventDispatcher: fakeEventDispatcher, - // logger: silentLogger, - // eventBatchSize: null, - // }); - // sinon.assert.calledWithExactly( - // eventProcessor.createEventProcessor, - // sinon.match({ - // batchSize: 10, - // }) - // ); - // }); - // }); - - // describe('with a valid event batch size', function() { - // beforeEach(function() { - // sinon.stub(eventProcessorConfigValidator, 'validateEventBatchSize').returns(true); - // }); - - // afterEach(function() { - // eventProcessorConfigValidator.validateEventBatchSize.restore(); - // }); - - // it('should use the provided event batch size', function() { - // optimizelyFactory.createInstance({ - // datafile: testData.getTestProjectConfigWithFeatures(), - // errorHandler: fakeErrorHandler, - // eventDispatcher: fakeEventDispatcher, - // logger: silentLogger, - // eventBatchSize: 300, - // }); - // sinon.assert.calledWithExactly( - // eventProcessor.createEventProcessor, - // sinon.match({ - // batchSize: 300, - // }) - // ); - // }); - // }); - // }); }); describe('ODP/ATS', () => { @@ -624,627 +470,118 @@ describe('javascript-sdk (Browser)', function() { requestParams.clear(); }); - it('should send identify event by default when initialized', async () => { - new OptimizelyUserContext({ - optimizely: fakeOptimizely, - userId: testFsUserId, - }); - - await fakeOptimizely.onReady(); - - sinon.assert.calledOnce(fakeOptimizely.identifyUser); - - sinon.assert.calledWith(fakeOptimizely.identifyUser, testFsUserId); - }); - - it('should log info when odp is disabled', () => { - const disabledClient = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { disabled: true }, - odpManager: BrowserOdpManager.createInstance({ - logger, - odpOptions: { - disabled: true, - }, - }), - }); - - sinon.assert.calledWith(logger.log, optimizelyFactory.enums.LOG_LEVEL.INFO, LOG_MESSAGES.ODP_DISABLED); - }); - - it('should include the VUID instantation promise of Browser ODP Manager in the Optimizely client onReady promise dependency array', () => { - const client = optimizelyFactory.createInstance({ - projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), - onRunning: Promise.resolve(), - }), - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpManager: BrowserOdpManager.createInstance({ - logger, - }), - }); - - client - .onReady() - .then(() => { - assert.isDefined(client.odpManager.initPromise); - client.odpManager.initPromise - .then(() => { - assert.isTrue(true); - }) - .catch(() => { - assert.isTrue(false); - }); - assert.isDefined(client.odpManager.getVuid()); - }) - .catch(() => { - assert.isTrue(false); - }); - - sinon.assert.neverCalledWith(logger.log, optimizelyFactory.enums.LOG_LEVEL.ERROR); - }); - - it('should accept a valid custom cache size', () => { - const client = optimizelyFactory.createInstance({ - projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), - onRunning: Promise.resolve(), - }), - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpManager: BrowserOdpManager.createInstance({ - logger, - odpOptions: { - segmentsCacheSize: 10, - }, - }), - }); - - sinon.assert.calledWith( - logger.log, - optimizelyFactory.enums.LOG_LEVEL.DEBUG, - 'Provisioning cache with maxSize of 10' - ); - }); - - it('should accept a custom cache timeout', () => { - const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpManager: BrowserOdpManager.createInstance({ - logger, - odpOptions: { - segmentsCacheTimeout: 10, - }, - }), - }); - - sinon.assert.calledWith( - logger.log, - optimizelyFactory.enums.LOG_LEVEL.DEBUG, - 'Provisioning cache with timeout of 10' - ); - }); - - it('should accept both a custom cache size and timeout', () => { - const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - segmentsCacheSize: 10, - segmentsCacheTimeout: 10, - }, - }); - - sinon.assert.calledWith( - logger.log, - optimizelyFactory.enums.LOG_LEVEL.DEBUG, - 'Provisioning cache with maxSize of 10' - ); - - sinon.assert.calledWith( - logger.log, - optimizelyFactory.enums.LOG_LEVEL.DEBUG, - 'Provisioning cache with timeout of 10' - ); - }); - - it('should accept a valid custom odp segment manager', async () => { - const fakeSegmentManager = { - fetchQualifiedSegments: sinon.stub().returns(['a']), - updateSettings: sinon.spy(), - }; - - const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - segmentManager: fakeSegmentManager, - }, - }); - - projectConfigManager.pushUpdate(config); - - const readyData = await client.onReady(); - - sinon.assert.called(fakeSegmentManager.updateSettings); - - const segments = await client.fetchQualifiedSegments(testVuid); - assert.deepEqual(segments, ['a']); - - sinon.assert.notCalled(logger.error); - sinon.assert.called(fakeSegmentManager.fetchQualifiedSegments); - }); - - it('should accept a valid custom odp event manager', async () => { - const fakeEventManager = { - start: sinon.spy(), - updateSettings: sinon.spy(), - flush: sinon.spy(), - stop: sinon.spy(), - registerVuid: sinon.spy(), - identifyUser: sinon.spy(), - sendEvent: sinon.spy(), - }; - - const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - disabled: false, - eventManager: fakeEventManager, - }, - }); - projectConfigManager.pushUpdate(config); - - await client.onReady(); - - sinon.assert.called(fakeEventManager.start); - }); - - it('should send an odp event when calling sendOdpEvent with valid parameters', async () => { - const fakeEventManager = { - updateSettings: sinon.spy(), - start: sinon.spy(), - stop: sinon.spy(), - registerVuid: sinon.spy(), - identifyUser: sinon.spy(), - sendEvent: sinon.spy(), - flush: sinon.spy(), - }; - - const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - eventManager: fakeEventManager, - }, - }); - - projectConfigManager.pushUpdate(config); - await client.onReady(); - - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); - - sinon.assert.notCalled(logger.error); - sinon.assert.called(fakeEventManager.sendEvent); - }); - - it('should augment odp events with user agent data if userAgentParser is provided', async () => { - const userAgentParser = { - parseUserAgentInfo() { - return { - os: { name: 'windows', version: '11' }, - device: { type: 'laptop', model: 'thinkpad' }, - }; - }, - }; - - const fakeRequestHandler = { - makeRequest: sinon.spy(function(requestUrl, headers, method, data) { - return { - abort: () => {}, - responsePromise: Promise.resolve({ statusCode: 200 }), - }; - }), - }; - - const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - userAgentParser, - eventRequestHandler: fakeRequestHandler, - }, - }); - projectConfigManager.pushUpdate(config); - await client.onReady(); - - client.sendOdpEvent('test', '', new Map([['eamil', 'test@test.test']]), new Map([['key', 'value']])); - clock.tick(10000); - - const eventRequestUrl = new URL(fakeRequestHandler.makeRequest.lastCall.args[0]); - const searchParams = eventRequestUrl.searchParams; - - assert.equal(searchParams.get('os'), 'windows'); - assert.equal(searchParams.get('os_version'), '11'); - assert.equal(searchParams.get('device_type'), 'laptop'); - assert.equal(searchParams.get('model'), 'thinkpad'); - }); - - it('should convert fs-user-id, FS-USER-ID, and FS_USER_ID to fs_user_id identifier when calling sendOdpEvent', async () => { - const fakeEventManager = { - updateSettings: sinon.spy(), - start: sinon.spy(), - stop: sinon.spy(), - registerVuid: sinon.spy(), - identifyUser: sinon.spy(), - sendEvent: sinon.spy(), - flush: sinon.spy(), - }; - const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - eventManager: fakeEventManager, - }, - }); - projectConfigManager.pushUpdate(config); - await client.onReady(); - - // fs-user-id - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED, undefined, new Map([['fs-user-id', 'fsUserA']])); - sinon.assert.notCalled(logger.error); - sinon.assert.neverCalledWith(logger.warn, LOG_MESSAGES.ODP_SEND_EVENT_IDENTIFIER_CONVERSION_FAILED); - - const sendEventArgs1 = fakeEventManager.sendEvent.args; - assert.deepEqual( - sendEventArgs1[0].toString(), - new OdpEvent('fullstack', 'client_initialized', new Map([['fs_user_id', 'fsUserA']]), new Map()).toString() - ); - - // FS-USER-ID - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED, undefined, new Map([['FS-USER-ID', 'fsUserA']])); - sinon.assert.notCalled(logger.error); - sinon.assert.neverCalledWith(logger.warn, LOG_MESSAGES.ODP_SEND_EVENT_IDENTIFIER_CONVERSION_FAILED); - - const sendEventArgs2 = fakeEventManager.sendEvent.args; - assert.deepEqual( - sendEventArgs2[0].toString(), - new OdpEvent('fullstack', 'client_initialized', new Map([['fs_user_id', 'fsUserA']]), new Map()).toString() - ); - - // FS_USER_ID - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED, undefined, new Map([['FS_USER_ID', 'fsUserA']])); - sinon.assert.notCalled(logger.error); - sinon.assert.neverCalledWith(logger.warn, LOG_MESSAGES.ODP_SEND_EVENT_IDENTIFIER_CONVERSION_FAILED); - - const sendEventArgs3 = fakeEventManager.sendEvent.args; - assert.deepEqual( - sendEventArgs3[0].toString(), - new OdpEvent('fullstack', 'client_initialized', new Map([['fs_user_id', 'fsUserA']]), new Map()).toString() - ); - - // fs_user_id - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED, undefined, new Map([['fs_user_id', 'fsUserA']])); - sinon.assert.notCalled(logger.error); - sinon.assert.neverCalledWith(logger.warn, LOG_MESSAGES.ODP_SEND_EVENT_IDENTIFIER_CONVERSION_FAILED); - - const sendEventArgs4 = fakeEventManager.sendEvent.args; - assert.deepEqual( - sendEventArgs4[0].toString(), - new OdpEvent('fullstack', 'client_initialized', new Map([['fs_user_id', 'fsUserA']]), new Map()).toString() - ); - }); - - it('should throw an error and not send an odp event when calling sendOdpEvent with an invalid action input', async () => { - const fakeEventManager = { - updateSettings: sinon.spy(), - start: sinon.spy(), - stop: sinon.spy(), - registerVuid: sinon.spy(), - identifyUser: sinon.spy(), - sendEvent: sinon.spy(), - flush: sinon.spy(), - }; - - const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - eventManager: fakeEventManager, - }, - }); - - projectConfigManager.pushUpdate(config); - await client.onReady(); - - client.sendOdpEvent(''); - sinon.assert.called(logger.error); - - client.sendOdpEvent(null); - sinon.assert.calledTwice(logger.error); - - client.sendOdpEvent(undefined); - sinon.assert.calledThrice(logger.error); - - sinon.assert.notCalled(fakeEventManager.sendEvent); - }); - - it('should use fullstack as a fallback value for the odp event when calling sendOdpEvent with an empty type input', async () => { - const fakeEventManager = { - updateSettings: sinon.spy(), - start: sinon.spy(), - stop: sinon.spy(), - registerVuid: sinon.spy(), - identifyUser: sinon.spy(), - sendEvent: sinon.spy(), - flush: sinon.spy(), - }; - - const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); + // TODO: these tests should be elsewhere + // it('should send an odp event when calling sendOdpEvent with valid parameters', async () => { + // const fakeEventManager = { + // updateSettings: sinon.spy(), + // start: sinon.spy(), + // stop: sinon.spy(), + // registerVuid: sinon.spy(), + // identifyUser: sinon.spy(), + // sendEvent: sinon.spy(), + // flush: sinon.spy(), + // }; - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - eventManager: fakeEventManager, - }, - }); - projectConfigManager.pushUpdate(config); - await client.onReady(); - - client.sendOdpEvent('dummy-action', ''); - - const sendEventArgs = fakeEventManager.sendEvent.args; - - const expectedEventArgs = new OdpEvent('fullstack', 'dummy-action', new Map(), new Map()); - assert.deepEqual(JSON.stringify(sendEventArgs[0][0]), JSON.stringify(expectedEventArgs)); - }); - - it('should log an error when attempting to send an odp event when odp is disabled', async () => { - const config = createProjectConfig(testData.getTestProjectConfigWithFeatures()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - disabled: true, - }, - }); - - projectConfigManager.pushUpdate(config); - - await client.onReady(); - - assert.isUndefined(client.odpManager); - sinon.assert.calledWith(logger.log, optimizelyFactory.enums.LOG_LEVEL.INFO, 'ODP Disabled.'); - - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); - - sinon.assert.calledWith( - logger.error, - optimizelyFactory.enums.ERROR_MESSAGES.ODP_EVENT_FAILED_ODP_MANAGER_MISSING - ); - }); - - it('should log a warning when attempting to use an event batch size other than 1', async () => { - const config = createProjectConfig(testData.getTestProjectConfigWithFeatures()); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); - - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - eventBatchSize: 5, - }, - }); - - projectConfigManager.pushUpdate(config); - - await client.onReady(); - - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); + // const config = createProjectConfig(testData.getOdpIntegratedConfigWithoutSegments()); + // const projectConfigManager = getMockProjectConfigManager({ + // initConfig: config, + // onRunning: Promise.resolve(), + // }); - sinon.assert.calledWith( - logger.log, - optimizelyFactory.enums.LOG_LEVEL.WARNING, - 'ODP event batch size must be 1 in the browser.' - ); - assert(client.odpManager.eventManager.batchSize, 1); - }); + // const client = optimizelyFactory.createInstance({ + // projectConfigManager, + // errorHandler: fakeErrorHandler, + // eventDispatcher: fakeEventDispatcher, + // eventBatchSize: null, + // logger, + // odpOptions: { + // eventManager: fakeEventManager, + // }, + // }); - it('should send an odp event to the browser endpoint', async () => { - const odpConfig = new OdpConfig(); + // projectConfigManager.pushUpdate(config); + // await client.onReady(); - const apiManager = new BrowserOdpEventApiManager(mockRequestHandler, logger); - const eventManager = new BrowserOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine: 'javascript-sdk', - clientVersion: 'great', - }); + // client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); - let datafile = testData.getOdpIntegratedConfigWithSegments(); - const config = createProjectConfig(datafile); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); + // sinon.assert.notCalled(logger.error); + // sinon.assert.called(fakeEventManager.sendEvent); + // }); - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - odpConfig, - eventManager, - }, - }); - projectConfigManager.pushUpdate(config); - await client.onReady(); + // it('should log an error when attempting to send an odp event when odp is disabled', async () => { + // const config = createProjectConfig(testData.getTestProjectConfigWithFeatures()); + // const projectConfigManager = getMockProjectConfigManager({ + // initConfig: config, + // onRunning: Promise.resolve(), + // }); - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); + // const client = optimizelyFactory.createInstance({ + // projectConfigManager, + // errorHandler: fakeErrorHandler, + // eventDispatcher: fakeEventDispatcher, + // eventBatchSize: null, + // logger, + // odpOptions: { + // disabled: true, + // }, + // }); - // wait for request to be sent - clock.tick(100); + // projectConfigManager.pushUpdate(config); - let publicKey = datafile.integrations[0].publicKey; - let pixelUrl = datafile.integrations[0].pixelUrl; + // await client.onReady(); - const pixelApiEndpoint = `${pixelUrl}/v2/zaius.gif`; - let requestEndpoint = new URL(requestParams.get('endpoint')); - assert.equal(requestEndpoint.origin + requestEndpoint.pathname, pixelApiEndpoint); - assert.equal(requestParams.get('method'), 'GET'); + // assert.isUndefined(client.odpManager); + // sinon.assert.calledWith(logger.log, optimizelyFactory.enums.LOG_LEVEL.INFO, 'ODP Disabled.'); - let searchParams = requestEndpoint.searchParams; - assert.lengthOf(searchParams.get('idempotence_id'), 36); - assert.equal(searchParams.get('data_source'), 'javascript-sdk'); - assert.equal(searchParams.get('data_source_type'), 'sdk'); - assert.equal(searchParams.get('data_source_version'), 'great'); - assert.equal(searchParams.get('tracker_id'), publicKey); - assert.equal(searchParams.get('event_type'), 'fullstack'); - assert.equal(searchParams.get('vdl_action'), ODP_EVENT_ACTION.INITIALIZED); - assert.isTrue(searchParams.get('vuid').startsWith('vuid_')); - assert.isNotNull(searchParams.get('data_source_version')); + // client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); - sinon.assert.notCalled(logger.error); - }); + // sinon.assert.calledWith( + // logger.error, + // optimizelyFactory.enums.ERROR_MESSAGES.ODP_EVENT_FAILED_ODP_MANAGER_MISSING + // ); + // }); - it('should send odp client_initialized on client instantiation', async () => { - const odpConfig = new OdpConfig('key', 'host', 'pixel', []); - const apiManager = new BrowserOdpEventApiManager(mockRequestHandler, logger); - sinon.spy(apiManager, 'sendEvents'); - const eventManager = new BrowserOdpEventManager({ - odpConfig, - apiManager, - logger, - }); - const datafile = testData.getOdpIntegratedConfigWithSegments(); - const config = createProjectConfig(datafile); - const projectConfigManager = getMockProjectConfigManager({ - initConfig: config, - onRunning: Promise.resolve(), - }); + // it('should send odp client_initialized on client instantiation', async () => { + // const odpConfig = new OdpConfig('key', 'host', 'pixel', []); + // const apiManager = new BrowserOdpEventApiManager(mockRequestHandler, logger); + // sinon.spy(apiManager, 'sendEvents'); + // const eventManager = new BrowserOdpEventManager({ + // odpConfig, + // apiManager, + // logger, + // }); + // const datafile = testData.getOdpIntegratedConfigWithSegments(); + // const config = createProjectConfig(datafile); + // const projectConfigManager = getMockProjectConfigManager({ + // initConfig: config, + // onRunning: Promise.resolve(), + // }); - const client = optimizelyFactory.createInstance({ - projectConfigManager, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - odpConfig, - eventManager, - }, - }); + // const client = optimizelyFactory.createInstance({ + // projectConfigManager, + // errorHandler: fakeErrorHandler, + // eventDispatcher: fakeEventDispatcher, + // eventBatchSize: null, + // logger, + // odpOptions: { + // odpConfig, + // eventManager, + // }, + // }); - projectConfigManager.pushUpdate(config); - await client.onReady(); + // projectConfigManager.pushUpdate(config); + // await client.onReady(); - clock.tick(100); + // clock.tick(100); - const [_, events] = apiManager.sendEvents.getCall(0).args; + // const [_, events] = apiManager.sendEvents.getCall(0).args; - const [firstEvent] = events; - assert.equal(firstEvent.action, 'client_initialized'); - assert.equal(firstEvent.type, 'fullstack'); - }); + // const [firstEvent] = events; + // assert.equal(firstEvent.action, 'client_initialized'); + // assert.equal(firstEvent.type, 'fullstack'); + // }); }); }); }); diff --git a/lib/index.browser.ts b/lib/index.browser.ts index 05cc88075..7317540db 100644 --- a/lib/index.browser.ts +++ b/lib/index.browser.ts @@ -24,14 +24,16 @@ import * as enums from './utils/enums'; import * as loggerPlugin from './plugins/logger'; import { createNotificationCenter } from './notification_center'; import { OptimizelyDecideOption, Client, Config, OptimizelyOptions } from './shared_types'; -import { BrowserOdpManager } from './odp/odp_manager.browser'; import Optimizely from './optimizely'; -import { IUserAgentParser } from './odp/ua_parser/user_agent_parser'; +import { UserAgentParser } from './odp/ua_parser/user_agent_parser'; import { getUserAgentParser } from './odp/ua_parser/ua_parser.browser'; import * as commonExports from './common_exports'; import { PollingConfigManagerConfig } from './project_config/config_manager_factory'; import { createPollingProjectConfigManager } from './project_config/config_manager_factory.browser'; import { createBatchEventProcessor, createForwardingEventProcessor } from './event_processor/event_processor_factory.browser'; +import { createVuidManager } from './vuid/vuid_manager_factory.browser'; +import { createOdpManager } from './odp/odp_manager_factory.browser'; + const logger = getLogger(); logHelper.setLogHandler(loggerPlugin.createLogger()); @@ -75,73 +77,19 @@ const createInstance = function(config: Config): Client | null { logger.error(ex); } - // let eventDispatcher; - // // prettier-ignore - // if (config.eventDispatcher == null) { // eslint-disable-line eqeqeq - // // only wrap the event dispatcher with pending events retry if the user didnt override - // eventDispatcher = new LocalStoragePendingEventsDispatcher({ - // eventDispatcher: defaultEventDispatcher, - // }); - - // if (!hasRetriedEvents) { - // eventDispatcher.sendPendingEvents(); - // hasRetriedEvents = true; - // } - // } else { - // eventDispatcher = config.eventDispatcher; - // } - - // let closingDispatcher = config.closingEventDispatcher; - - // if (!config.eventDispatcher && !closingDispatcher && window.navigator && 'sendBeacon' in window.navigator) { - // closingDispatcher = sendBeaconEventDispatcher; - // } - - // let eventBatchSize = config.eventBatchSize; - // let eventFlushInterval = config.eventFlushInterval; - - // if (!eventProcessorConfigValidator.validateEventBatchSize(config.eventBatchSize)) { - // logger.warn('Invalid eventBatchSize %s, defaulting to %s', config.eventBatchSize, DEFAULT_EVENT_BATCH_SIZE); - // eventBatchSize = DEFAULT_EVENT_BATCH_SIZE; - // } - // if (!eventProcessorConfigValidator.validateEventFlushInterval(config.eventFlushInterval)) { - // logger.warn( - // 'Invalid eventFlushInterval %s, defaulting to %s', - // config.eventFlushInterval, - // DEFAULT_EVENT_FLUSH_INTERVAL - // ); - // eventFlushInterval = DEFAULT_EVENT_FLUSH_INTERVAL; - // } - const errorHandler = getErrorHandler(); const notificationCenter = createNotificationCenter({ logger: logger, errorHandler: errorHandler }); - // const eventProcessorConfig = { - // dispatcher: eventDispatcher, - // closingDispatcher, - // flushInterval: eventFlushInterval, - // batchSize: eventBatchSize, - // maxQueueSize: config.eventMaxQueueSize || DEFAULT_EVENT_MAX_QUEUE_SIZE, - // notificationCenter, - // }; - - const odpExplicitlyOff = config.odpOptions?.disabled === true; - if (odpExplicitlyOff) { - logger.info(enums.LOG_MESSAGES.ODP_DISABLED); - } - const { clientEngine, clientVersion } = config; const optimizelyOptions: OptimizelyOptions = { - clientEngine: enums.JAVASCRIPT_CLIENT_ENGINE, ...config, - // eventProcessor: eventProcessor.createEventProcessor(eventProcessorConfig), + clientEngine: clientEngine || enums.JAVASCRIPT_CLIENT_ENGINE, + clientVersion: clientVersion || enums.CLIENT_VERSION, logger, errorHandler, notificationCenter, isValidInstance, - odpManager: odpExplicitlyOff ? undefined - : BrowserOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), }; const optimizely = new Optimizely(optimizelyOptions); @@ -192,11 +140,13 @@ export { createInstance, __internalResetRetryState, OptimizelyDecideOption, - IUserAgentParser, + UserAgentParser as IUserAgentParser, getUserAgentParser, createPollingProjectConfigManager, createForwardingEventProcessor, createBatchEventProcessor, + createOdpManager, + createVuidManager, }; export * from './common_exports'; @@ -217,6 +167,8 @@ export default { createPollingProjectConfigManager, createForwardingEventProcessor, createBatchEventProcessor, + createOdpManager, + createVuidManager, }; export * from './export_types'; diff --git a/lib/index.node.ts b/lib/index.node.ts index a5a3b2968..63f7e16e5 100644 --- a/lib/index.node.ts +++ b/lib/index.node.ts @@ -23,10 +23,11 @@ import defaultErrorHandler from './plugins/error_handler'; import defaultEventDispatcher from './event_processor/event_dispatcher/default_dispatcher.node'; import { createNotificationCenter } from './notification_center'; import { OptimizelyDecideOption, Client, Config } from './shared_types'; -import { NodeOdpManager } from './odp/odp_manager.node'; import * as commonExports from './common_exports'; import { createPollingProjectConfigManager } from './project_config/config_manager_factory.node'; import { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor/event_processor_factory.node'; +import { createVuidManager } from './vuid/vuid_manager_factory.node'; +import { createOdpManager } from './odp/odp_manager_factory.node'; const logger = getLogger(); setLogLevel(LogLevel.ERROR); @@ -72,53 +73,20 @@ const createInstance = function(config: Config): Client | null { } } - // let eventBatchSize = config.eventBatchSize; - // let eventFlushInterval = config.eventFlushInterval; - - // if (!eventProcessorConfigValidator.validateEventBatchSize(config.eventBatchSize)) { - // logger.warn('Invalid eventBatchSize %s, defaulting to %s', config.eventBatchSize, DEFAULT_EVENT_BATCH_SIZE); - // eventBatchSize = DEFAULT_EVENT_BATCH_SIZE; - // } - // if (!eventProcessorConfigValidator.validateEventFlushInterval(config.eventFlushInterval)) { - // logger.warn( - // 'Invalid eventFlushInterval %s, defaulting to %s', - // config.eventFlushInterval, - // DEFAULT_EVENT_FLUSH_INTERVAL - // ); - // eventFlushInterval = DEFAULT_EVENT_FLUSH_INTERVAL; - // } const errorHandler = getErrorHandler(); const notificationCenter = createNotificationCenter({ logger: logger, errorHandler: errorHandler }); - // const eventProcessorConfig = { - // dispatcher: config.eventDispatcher || defaultEventDispatcher, - // flushInterval: eventFlushInterval, - // batchSize: eventBatchSize, - // maxQueueSize: config.eventMaxQueueSize || DEFAULT_EVENT_MAX_QUEUE_SIZE, - // notificationCenter, - // }; - - // const eventProcessor = createEventProcessor(eventProcessorConfig); - // const eventProcessor = config.eventProcessor; - - const odpExplicitlyOff = config.odpOptions?.disabled === true; - if (odpExplicitlyOff) { - logger.info(enums.LOG_MESSAGES.ODP_DISABLED); - } - const { clientEngine, clientVersion } = config; const optimizelyOptions = { - clientEngine: enums.NODE_CLIENT_ENGINE, ...config, - // eventProcessor, + clientEngine: clientEngine || enums.NODE_CLIENT_ENGINE, + clientVersion: clientVersion || enums.CLIENT_VERSION, logger, errorHandler, notificationCenter, isValidInstance, - odpManager: odpExplicitlyOff ? undefined - : NodeOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), }; return new Optimizely(optimizelyOptions); @@ -144,6 +112,8 @@ export { createPollingProjectConfigManager, createForwardingEventProcessor, createBatchEventProcessor, + createOdpManager, + createVuidManager, }; export * from './common_exports'; @@ -161,6 +131,8 @@ export default { createPollingProjectConfigManager, createForwardingEventProcessor, createBatchEventProcessor, + createOdpManager, + createVuidManager, }; export * from './export_types'; diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts index c0417d588..8cedf06d5 100644 --- a/lib/index.react_native.ts +++ b/lib/index.react_native.ts @@ -23,10 +23,11 @@ import * as loggerPlugin from './plugins/logger/index.react_native'; import defaultEventDispatcher from './event_processor/event_dispatcher/default_dispatcher.browser'; import { createNotificationCenter } from './notification_center'; import { OptimizelyDecideOption, Client, Config } from './shared_types'; -import { BrowserOdpManager } from './odp/odp_manager.browser'; import * as commonExports from './common_exports'; import { createPollingProjectConfigManager } from './project_config/config_manager_factory.react_native'; import { createBatchEventProcessor, createForwardingEventProcessor } from './event_processor/event_processor_factory.react_native'; +import { createOdpManager } from './odp/odp_manager_factory.react_native'; +import { createVuidManager } from './vuid/vuid_manager_factory.react_native'; import 'fast-text-encoding'; import 'react-native-get-random-values'; @@ -70,53 +71,19 @@ const createInstance = function(config: Config): Client | null { logger.error(ex); } - // let eventBatchSize = config.eventBatchSize; - // let eventFlushInterval = config.eventFlushInterval; - - // if (!eventProcessorConfigValidator.validateEventBatchSize(config.eventBatchSize)) { - // logger.warn('Invalid eventBatchSize %s, defaulting to %s', config.eventBatchSize, DEFAULT_EVENT_BATCH_SIZE); - // eventBatchSize = DEFAULT_EVENT_BATCH_SIZE; - // } - // if (!eventProcessorConfigValidator.validateEventFlushInterval(config.eventFlushInterval)) { - // logger.warn( - // 'Invalid eventFlushInterval %s, defaulting to %s', - // config.eventFlushInterval, - // DEFAULT_EVENT_FLUSH_INTERVAL - // ); - // eventFlushInterval = DEFAULT_EVENT_FLUSH_INTERVAL; - // } - const errorHandler = getErrorHandler(); const notificationCenter = createNotificationCenter({ logger: logger, errorHandler: errorHandler }); - // const eventProcessorConfig = { - // dispatcher: config.eventDispatcher || defaultEventDispatcher, - // flushInterval: eventFlushInterval, - // batchSize: eventBatchSize, - // maxQueueSize: config.eventMaxQueueSize || DEFAULT_EVENT_MAX_QUEUE_SIZE, - // notificationCenter, - // peristentCacheProvider: config.persistentCacheProvider, - // }; - - // const eventProcessor = createEventProcessor(eventProcessorConfig); - - const odpExplicitlyOff = config.odpOptions?.disabled === true; - if (odpExplicitlyOff) { - logger.info(enums.LOG_MESSAGES.ODP_DISABLED); - } - const { clientEngine, clientVersion } = config; const optimizelyOptions = { - clientEngine: enums.REACT_NATIVE_JS_CLIENT_ENGINE, ...config, - // eventProcessor, + clientEngine: clientEngine || enums.REACT_NATIVE_JS_CLIENT_ENGINE, + clientVersion: clientVersion || enums.CLIENT_VERSION, logger, errorHandler, notificationCenter, isValidInstance: isValidInstance, - odpManager: odpExplicitlyOff ? undefined - :BrowserOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), }; // If client engine is react, convert it to react native. @@ -147,6 +114,8 @@ export { createPollingProjectConfigManager, createForwardingEventProcessor, createBatchEventProcessor, + createOdpManager, + createVuidManager, }; export * from './common_exports'; @@ -164,6 +133,8 @@ export default { createPollingProjectConfigManager, createForwardingEventProcessor, createBatchEventProcessor, + createOdpManager, + createVuidManager, }; export * from './export_types'; diff --git a/lib/odp/constant.ts b/lib/odp/constant.ts new file mode 100644 index 000000000..c33f3f0c9 --- /dev/null +++ b/lib/odp/constant.ts @@ -0,0 +1,28 @@ +/** + * Copyright 2024, 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 enum ODP_USER_KEY { + VUID = 'vuid', + FS_USER_ID = 'fs_user_id', + FS_USER_ID_ALIAS = 'fs-user-id', +} + +export enum ODP_EVENT_ACTION { + IDENTIFIED = 'identified', + INITIALIZED = 'client_initialized', +} + +export const ODP_DEFAULT_EVENT_TYPE = 'fullstack'; diff --git a/lib/odp/event_manager/event_api_manager.browser.ts b/lib/odp/event_manager/event_api_manager.browser.ts deleted file mode 100644 index 26ed98136..000000000 --- a/lib/odp/event_manager/event_api_manager.browser.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright 2024, 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 { OdpEvent } from './odp_event'; -import { OdpEventApiManager } from './odp_event_api_manager'; -import { LogLevel } from '../../modules/logging'; -import { OdpConfig } from '../odp_config'; -import { HttpMethod } from '../../utils/http_request_handler/http'; - -const EVENT_SENDING_FAILURE_MESSAGE = 'ODP event send failed'; - -const pixelApiPath = 'v2/zaius.gif'; - -export class BrowserOdpEventApiManager extends OdpEventApiManager { - protected shouldSendEvents(events: OdpEvent[]): boolean { - if (events.length <= 1) { - return true; - } - this.getLogger().log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (browser only supports batch size 1)`); - return false; - } - - private getPixelApiEndpoint(odpConfig: OdpConfig): string { - const pixelUrl = odpConfig.pixelUrl; - const pixelApiEndpoint = new URL(pixelApiPath, pixelUrl).href; - return pixelApiEndpoint; - } - - protected generateRequestData( - odpConfig: OdpConfig, - events: OdpEvent[] - ): { method: HttpMethod; endpoint: string; headers: { [key: string]: string }; data: string } { - const pixelApiEndpoint = this.getPixelApiEndpoint(odpConfig); - - const apiKey = odpConfig.apiKey; - const method = 'GET'; - const event = events[0]; - const url = new URL(pixelApiEndpoint); - event.identifiers.forEach((v, k) => { - url.searchParams.append(k, v); - }); - event.data.forEach((v, k) => { - url.searchParams.append(k, v as string); - }); - url.searchParams.append('tracker_id', apiKey); - url.searchParams.append('event_type', event.type); - url.searchParams.append('vdl_action', event.action); - const endpoint = url.toString(); - return { - method, - endpoint, - headers: {}, - data: '', - }; - } -} diff --git a/lib/odp/event_manager/event_api_manager.node.ts b/lib/odp/event_manager/event_api_manager.node.ts deleted file mode 100644 index 3bf1f2ad4..000000000 --- a/lib/odp/event_manager/event_api_manager.node.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Copyright 2024, 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 '../odp_config'; -import { OdpEvent } from './odp_event' -import { OdpEventApiManager } from './odp_event_api_manager'; -import { HttpMethod } from '../../utils/http_request_handler/http'; - -export class NodeOdpEventApiManager extends OdpEventApiManager { - protected shouldSendEvents(events: OdpEvent[]): boolean { - return true; - } - - protected generateRequestData( - odpConfig: OdpConfig, - events: OdpEvent[] - ): { method: HttpMethod; endpoint: string; headers: { [key: string]: string }; data: string } { - - const { apiHost, apiKey } = odpConfig; - - return { - method: 'POST', - endpoint: `${apiHost}/v3/events`, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - }, - data: JSON.stringify(events, this.replacer), - }; - } - - private replacer(_: unknown, value: unknown) { - if (value instanceof Map) { - return Object.fromEntries(value); - } else { - return value; - } - } -} diff --git a/lib/odp/event_manager/event_manager.browser.ts b/lib/odp/event_manager/event_manager.browser.ts deleted file mode 100644 index 4151c9b68..000000000 --- a/lib/odp/event_manager/event_manager.browser.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright 2023, 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. - */ - -import { IOdpEventManager, OdpEventManager } from './odp_event_manager'; -import { LogLevel } from '../../modules/logging'; -import { OdpEvent } from './odp_event'; - -const DEFAULT_BROWSER_QUEUE_SIZE = 100; - -export class BrowserOdpEventManager extends OdpEventManager implements IOdpEventManager { - protected initParams( - batchSize: number | undefined, - queueSize: number | undefined, - flushInterval: number | undefined - ): void { - this.queueSize = queueSize || DEFAULT_BROWSER_QUEUE_SIZE; - - // disable event batching for browser - this.batchSize = 1; - this.flushInterval = 0; - - if (typeof batchSize !== 'undefined' && batchSize !== 1) { - this.getLogger().log(LogLevel.WARNING, 'ODP event batch size must be 1 in the browser.'); - } - - if (typeof flushInterval !== 'undefined' && flushInterval !== 0) { - this.getLogger().log(LogLevel.WARNING, 'ODP event flush interval must be 0 in the browser.'); - } - } - - protected discardEventsIfNeeded(): void { - // in Browser/client-side context, give debug message but leave events in queue - this.getLogger().log(LogLevel.DEBUG, 'ODPConfig not ready. Leaving events in queue.'); - } - - protected hasNecessaryIdentifiers = (event: OdpEvent): boolean => event.identifiers.size >= 0; -} diff --git a/lib/odp/event_manager/event_manager.node.ts b/lib/odp/event_manager/event_manager.node.ts deleted file mode 100644 index e057755a9..000000000 --- a/lib/odp/event_manager/event_manager.node.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright 2023, 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. - */ - -import { OdpEvent } from './odp_event'; -import { IOdpEventManager, OdpEventManager } from './odp_event_manager'; -import { LogLevel } from '../../modules/logging'; - -const DEFAULT_BATCH_SIZE = 10; -const DEFAULT_FLUSH_INTERVAL_MSECS = 1000; -const DEFAULT_SERVER_QUEUE_SIZE = 10000; - -export class NodeOdpEventManager extends OdpEventManager implements IOdpEventManager { - protected initParams( - batchSize: number | undefined, - queueSize: number | undefined, - flushInterval: number | undefined - ): void { - this.queueSize = queueSize || DEFAULT_SERVER_QUEUE_SIZE; - this.batchSize = batchSize || DEFAULT_BATCH_SIZE; - - if (flushInterval === 0) { - // disable event batching - this.batchSize = 1; - this.flushInterval = 0; - } else { - this.flushInterval = flushInterval || DEFAULT_FLUSH_INTERVAL_MSECS; - } - } - - protected discardEventsIfNeeded(): void { - // if Node/server-side context, empty queue items before ready state - this.getLogger().log(LogLevel.WARNING, 'ODPConfig not ready. Discarding events in queue.'); - this.queue = new Array(); - } - - protected hasNecessaryIdentifiers = (event: OdpEvent): boolean => event.identifiers.size >= 1; -} diff --git a/lib/odp/event_manager/odp_event.ts b/lib/odp/event_manager/odp_event.ts index e777789bc..062798d1b 100644 --- a/lib/odp/event_manager/odp_event.ts +++ b/lib/odp/event_manager/odp_event.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/odp/event_manager/odp_event_api_manager.spec.ts b/lib/odp/event_manager/odp_event_api_manager.spec.ts new file mode 100644 index 000000000..8f6a07fd2 --- /dev/null +++ b/lib/odp/event_manager/odp_event_api_manager.spec.ts @@ -0,0 +1,206 @@ +/** + * Copyright 2022-2024, 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 { describe, it, expect, vi } from 'vitest'; + +import { DefaultOdpEventApiManager, eventApiRequestGenerator, pixelApiRequestGenerator } from './odp_event_api_manager'; +import { OdpEvent } from './odp_event'; +import { OdpConfig } from '../odp_config'; + +const data1 = new Map(); +data1.set('key11', 'value-1'); +data1.set('key12', true); +data1.set('key13', 3.5); +data1.set('key14', null); + +const data2 = new Map(); + +data2.set('key2', 'value-2'); + +const ODP_EVENTS = [ + new OdpEvent('t1', 'a1', new Map([['id-key-1', 'id-value-1']]), data1), + new OdpEvent('t2', 'a2', new Map([['id-key-2', 'id-value-2']]), data2), +]; + +const API_KEY = 'test-api-key'; +const API_HOST = 'https://odp.example.com'; +const PIXEL_URL = 'https://odp.pixel.com'; + +const odpConfig = new OdpConfig(API_KEY, API_HOST, PIXEL_URL, []); + +import { getMockRequestHandler } from '../../tests/mock/mock_request_handler'; + +describe('DefaultOdpEventApiManager', () => { + it('should generate the event request using the correct odp config and event', async () => { + const mockRequestHandler = getMockRequestHandler(); + mockRequestHandler.makeRequest.mockReturnValue({ + responsePromise: Promise.resolve({ + statusCode: 200, + body: '', + headers: {}, + }), + }); + const requestGenerator = vi.fn().mockReturnValue({ + method: 'PATCH', + endpoint: 'https://odp.example.com/v3/events', + headers: { + 'x-api-key': 'test-api', + }, + data: 'event-data', + }); + + const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator); + manager.sendEvents(odpConfig, ODP_EVENTS); + + expect(requestGenerator.mock.calls[0][0]).toEqual(odpConfig); + expect(requestGenerator.mock.calls[0][1]).toEqual(ODP_EVENTS); + }); + + it('should send the correct request using the request handler', async () => { + const mockRequestHandler = getMockRequestHandler(); + mockRequestHandler.makeRequest.mockReturnValue({ + responsePromise: Promise.resolve({ + statusCode: 200, + body: '', + headers: {}, + }), + }); + const requestGenerator = vi.fn().mockReturnValue({ + method: 'PATCH', + endpoint: 'https://odp.example.com/v3/events', + headers: { + 'x-api-key': 'test-api', + }, + data: 'event-data', + }); + + const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator); + manager.sendEvents(odpConfig, ODP_EVENTS); + + expect(mockRequestHandler.makeRequest.mock.calls[0][0]).toEqual('https://odp.example.com/v3/events'); + expect(mockRequestHandler.makeRequest.mock.calls[0][1]).toEqual({ + 'x-api-key': 'test-api', + }); + expect(mockRequestHandler.makeRequest.mock.calls[0][2]).toEqual('PATCH'); + expect(mockRequestHandler.makeRequest.mock.calls[0][3]).toEqual('event-data'); + }); + + it('should return a promise that fails if the requestHandler response promise fails', async () => { + const mockRequestHandler = getMockRequestHandler(); + mockRequestHandler.makeRequest.mockReturnValue({ + responsePromise: Promise.reject(new Error('Request failed')), + }); + const requestGenerator = vi.fn().mockReturnValue({ + method: 'PATCH', + endpoint: 'https://odp.example.com/v3/events', + headers: { + 'x-api-key': 'test-api', + }, + data: 'event-data', + }); + + const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator); + const response = manager.sendEvents(odpConfig, ODP_EVENTS); + + await expect(response).rejects.toThrow('Request failed'); + }); + + it('should return a promise that resolves with correct response code from the requestHandler', async () => { + const mockRequestHandler = getMockRequestHandler(); + mockRequestHandler.makeRequest.mockReturnValue({ + responsePromise: Promise.resolve({ + statusCode: 226, + body: '', + headers: {}, + }), + }); + const requestGenerator = vi.fn().mockReturnValue({ + method: 'PATCH', + endpoint: 'https://odp.example.com/v3/events', + headers: { + 'x-api-key': 'test-api', + }, + data: 'event-data', + }); + + const manager = new DefaultOdpEventApiManager(mockRequestHandler, requestGenerator); + const response = manager.sendEvents(odpConfig, ODP_EVENTS); + + await expect(response).resolves.not.toThrow(); + const statusCode = await response.then((r) => r.statusCode); + expect(statusCode).toBe(226); + }); +}); + +describe('pixelApiRequestGenerator', () => { + it('should generate the correct request for the pixel API using only the first event', () => { + const request = pixelApiRequestGenerator(odpConfig, ODP_EVENTS); + expect(request.method).toBe('GET'); + const endpoint = new URL(request.endpoint); + expect(endpoint.origin).toBe(PIXEL_URL); + expect(endpoint.pathname).toBe('/v2/zaius.gif'); + expect(endpoint.searchParams.get('id-key-1')).toBe('id-value-1'); + expect(endpoint.searchParams.get('key11')).toBe('value-1'); + expect(endpoint.searchParams.get('key12')).toBe('true'); + expect(endpoint.searchParams.get('key13')).toBe('3.5'); + expect(endpoint.searchParams.get('key14')).toBe('null'); + expect(endpoint.searchParams.get('tracker_id')).toBe(API_KEY); + expect(endpoint.searchParams.get('event_type')).toBe('t1'); + expect(endpoint.searchParams.get('vdl_action')).toBe('a1'); + + expect(request.headers).toEqual({}); + expect(request.data).toBe(''); + }); +}); + +describe('eventApiRequestGenerator', () => { + it('should generate the correct request for the event API using all events', () => { + const request = eventApiRequestGenerator(odpConfig, ODP_EVENTS); + expect(request.method).toBe('POST'); + expect(request.endpoint).toBe('https://odp.example.com/v3/events'); + expect(request.headers).toEqual({ + 'Content-Type': 'application/json', + 'x-api-key': API_KEY, + }); + + const data = JSON.parse(request.data); + expect(data).toEqual([ + { + type: 't1', + action: 'a1', + identifiers: { + 'id-key-1': 'id-value-1', + }, + data: { + key11: 'value-1', + key12: true, + key13: 3.5, + key14: null, + }, + }, + { + type: 't2', + action: 'a2', + identifiers: { + 'id-key-2': 'id-value-2', + }, + data: { + key2: 'value-2', + }, + }, + ]); + }); +}); diff --git a/lib/odp/event_manager/odp_event_api_manager.ts b/lib/odp/event_manager/odp_event_api_manager.ts index 2a5249a28..8ea4f7060 100644 --- a/lib/odp/event_manager/odp_event_api_manager.ts +++ b/lib/odp/event_manager/odp_event_api_manager.ts @@ -14,103 +14,92 @@ * limitations under the License. */ -import { LogHandler, LogLevel } from '../../modules/logging'; +import { LoggerFacade } from '../../modules/logging'; import { OdpEvent } from './odp_event'; import { HttpMethod, RequestHandler } from '../../utils/http_request_handler/http'; import { OdpConfig } from '../odp_config'; -const EVENT_SENDING_FAILURE_MESSAGE = 'ODP event send failed'; - -/** - * Manager for communicating with the Optimizely Data Platform REST API - */ -export interface IOdpEventApiManager { - sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise; +export type EventDispatchResponse = { + statusCode?: number; +}; +export interface OdpEventApiManager { + sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise; } -/** - * Concrete implementation for accessing the ODP REST API - */ -export abstract class OdpEventApiManager implements IOdpEventApiManager { - /** - * Handler for recording execution logs - * @private - */ - private readonly logger: LogHandler; - - /** - * Handler for making external HTTP/S requests - * @private - */ - private readonly requestHandler: RequestHandler; +export type EventRequest = { + method: HttpMethod; + endpoint: string; + headers: Record; + data: string; +} - /** - * Creates instance to access Optimizely Data Platform (ODP) REST API - * @param requestHandler Desired request handler for testing - * @param logger Collect and record events/errors for this GraphQL implementation - */ - constructor(requestHandler: RequestHandler, logger: LogHandler) { +export type EventRequestGenerator = (odpConfig: OdpConfig, events: OdpEvent[]) => EventRequest; +export class DefaultOdpEventApiManager implements OdpEventApiManager { + private logger?: LoggerFacade; + private requestHandler: RequestHandler; + private requestGenerator: EventRequestGenerator; + + constructor( + requestHandler: RequestHandler, + requestDataGenerator: EventRequestGenerator, + logger?: LoggerFacade + ) { this.requestHandler = requestHandler; + this.requestGenerator = requestDataGenerator; this.logger = logger; } - getLogger(): LogHandler { - return this.logger; - } - - /** - * Service for sending ODP events to REST API - * @param events ODP events to send - * @returns Retry is true - if network or server error (5xx), otherwise false - */ - async sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise { - let shouldRetry = false; - + async sendEvents(odpConfig: OdpConfig, events: OdpEvent[]): Promise { if (events.length === 0) { - this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (no events)`); - return shouldRetry; + return {}; } - if (!this.shouldSendEvents(events)) { - return shouldRetry; - } + const { method, endpoint, headers, data } = this.requestGenerator(odpConfig, events); - const { method, endpoint, headers, data } = this.generateRequestData(odpConfig, events); - - let statusCode = 0; - try { - const request = this.requestHandler.makeRequest(endpoint, headers, method, data); - const response = await request.responsePromise; - statusCode = response.statusCode ?? statusCode; - } catch (err) { - let message = 'network error'; - if (err instanceof Error) { - message = (err as Error).message; - } - this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (${message})`); - shouldRetry = true; - } - - if (statusCode >= 400) { - this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (${statusCode})`); - } - - if (statusCode >= 500) { - shouldRetry = true; - } - - return shouldRetry; + const request = this.requestHandler.makeRequest(endpoint, headers, method, data); + return request.responsePromise; } +} - protected abstract shouldSendEvents(events: OdpEvent[]): boolean; +export const pixelApiRequestGenerator: EventRequestGenerator = (odpConfig: OdpConfig, events: OdpEvent[]): EventRequest => { + const pixelApiPath = 'v2/zaius.gif'; + const pixelApiEndpoint = new URL(pixelApiPath, odpConfig.pixelUrl); + + const apiKey = odpConfig.apiKey; + const method = 'GET'; + const event = events[0]; + + event.identifiers.forEach((v, k) => { + pixelApiEndpoint.searchParams.append(k, v); + }); + event.data.forEach((v, k) => { + pixelApiEndpoint.searchParams.append(k, v as string); + }); + pixelApiEndpoint.searchParams.append('tracker_id', apiKey); + pixelApiEndpoint.searchParams.append('event_type', event.type); + pixelApiEndpoint.searchParams.append('vdl_action', event.action); + const endpoint = pixelApiEndpoint.toString(); + + return { + method, + endpoint, + headers: {}, + data: '', + }; +} - protected abstract generateRequestData( - odpConfig: OdpConfig, - events: OdpEvent[] - ): { - method: HttpMethod; - endpoint: string; - headers: { [key: string]: string }; - data: string; +export const eventApiRequestGenerator: EventRequestGenerator = (odpConfig: OdpConfig, events: OdpEvent[]): EventRequest => { + const { apiHost, apiKey } = odpConfig; + + return { + method: 'POST', + endpoint: `${apiHost}/v3/events`, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }, + data: JSON.stringify(events, (_: unknown, value: unknown) => { + return value instanceof Map ? Object.fromEntries(value) : value; + }), }; } diff --git a/lib/odp/event_manager/odp_event_manager.spec.ts b/lib/odp/event_manager/odp_event_manager.spec.ts new file mode 100644 index 000000000..dfe8d496a --- /dev/null +++ b/lib/odp/event_manager/odp_event_manager.spec.ts @@ -0,0 +1,940 @@ +/** + * Copyright 2024, 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 { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { DefaultOdpEventManager } from './odp_event_manager'; +import { getMockRepeater } from '../../tests/mock/mock_repeater'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import { ServiceState } from '../../service'; +import { exhaustMicrotasks } from '../../tests/testUtils'; +import { OdpEvent } from './odp_event'; +import { OdpConfig } from '../odp_config'; +import { EventDispatchResponse } from './odp_event_api_manager'; +import { advanceTimersByTime } from '../../../tests/testUtils'; + +const API_KEY = 'test-api-key'; +const API_HOST = 'https://odp.example.com'; +const PIXEL_URL = 'https://odp.pixel.com'; +const SEGMENTS_TO_CHECK = ['segment1', 'segment2']; + +const config = new OdpConfig(API_KEY, API_HOST, PIXEL_URL, SEGMENTS_TO_CHECK); + +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 getMockApiManager = () => { + return { + sendEvents: vi.fn(), + }; +}; + +describe('DefaultOdpEventManager', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should be in new state after construction', () => { + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: getMockApiManager(), + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + expect(odpEventManager.getState()).toBe(ServiceState.New); + }); + + it('should stay in starting state if started with a odpIntegationConfig and not resolve or reject onRunning', async () => { + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: getMockApiManager(), + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + const onRunningHandler = vi.fn(); + odpEventManager.onRunning().then(onRunningHandler, onRunningHandler); + + odpEventManager.start(); + expect(odpEventManager.getState()).toBe(ServiceState.Starting); + + await exhaustMicrotasks(); + + expect(odpEventManager.getState()).toBe(ServiceState.Starting); + expect(onRunningHandler).not.toHaveBeenCalled(); + }); + + it('should move to running state and resolve onRunning() is start() is called after updateConfig()', async () => { + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: getMockApiManager(), + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: false, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + }); + + it('should move to running state and resolve onRunning() is updateConfig() is called after start()', async () => { + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: getMockApiManager(), + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.start(); + + odpEventManager.updateConfig({ + integrated: false, + }); + + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + }); + + it('should queue events until batchSize is reached', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for (let i = 0; i < 9; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + events.push(makeEvent(9)); + odpEventManager.sendEvent(events[9]); + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(1, config, events); + }); + + it('should send events immediately asynchronously if batchSize is 1', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 1, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + for (let i = 0; i < 10; i++) { + const event = makeEvent(i); + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(i + 1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(i + 1, config, [event]); + } + }); + + it('drops events and logs if the state is not running', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + expect(odpEventManager.getState()).toBe(ServiceState.New); + + const event = makeEvent(0); + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('drops events and logs if odpIntegrationConfig is not integrated', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: false, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + + const event = makeEvent(0); + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('drops event and logs if there is no identifier', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + + const event = new OdpEvent('test-type', 'test-action', new Map(), new Map()); + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('accepts string, number, boolean, and null values for data', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 1, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + + const data = new Map(); + data.set('string', 'string-value'); + data.set('number', 123); + data.set('boolean', true); + data.set('null', null); + + const event = new OdpEvent('test-type', 'test-action', new Map([['k', 'v']]), data); + + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + + expect(apiManager.sendEvents).toHaveBeenCalledTimes(1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(1, config, [event]); + }); + + it('should drop event and log if data contains values other than string, number, boolean, or null', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 1, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + + const data = new Map(); + data.set('string', 'string-value'); + data.set('number', 123); + data.set('boolean', true); + data.set('null', null); + data.set('invalid', new Date()); + + const event = new OdpEvent('test-type', 'test-action', new Map([['k', 'v']]), data); + + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('should drop event and log if action is empty', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 1, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + + const event = new OdpEvent('test-type', '', new Map([['k', 'v']]), new Map([['k', 'v']])); + + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('should use fullstack as type if type is empty', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 1, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + + const event = new OdpEvent('', 'test-action', new Map([['k', 'v']]), new Map([['k', 'v']])); + + odpEventManager.sendEvent(event); + await exhaustMicrotasks(); + + expect(apiManager.sendEvents).toHaveBeenCalledTimes(1); + expect(apiManager.sendEvents.mock.calls[0][1][0].type).toBe('fullstack'); + }); + + it('should transform identifiers with keys FS-USER-ID, fs-user-id and FS_USER_ID to fs_user_id', async () => { + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: apiManager, + batchSize: 3, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.setLogger(logger); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Running); + + const event1 = new OdpEvent('test-type', 'test-action', new Map([['FS-USER-ID', 'value1']]), new Map([['k', 'v']])); + const event2 = new OdpEvent('test-type', 'test-action', new Map([['fs-user-id', 'value2']]), new Map([['k', 'v']])); + const event3 = new OdpEvent('test-type', 'test-action', new Map([['FS_USER_ID', 'value3']]), new Map([['k', 'v']])); + + odpEventManager.sendEvent(event1); + odpEventManager.sendEvent(event2); + odpEventManager.sendEvent(event3); + await exhaustMicrotasks(); + + expect(apiManager.sendEvents).toHaveBeenCalledTimes(1); + expect(apiManager.sendEvents.mock.calls[0][1][0].identifiers.get('fs_user_id')).toBe('value1'); + expect(apiManager.sendEvents.mock.calls[0][1][1].identifiers.get('fs_user_id')).toBe('value2'); + expect(apiManager.sendEvents.mock.calls[0][1][2].identifiers.get('fs_user_id')).toBe('value3'); + }); + + it('should start the repeater when the first event is sent', async () => { + const repeater = getMockRepeater(); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: getMockApiManager(), + batchSize: 300, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + expect(repeater.start).not.toHaveBeenCalled(); + + for(let i = 0; i < 10; i++) { + odpEventManager.sendEvent(makeEvent(i)); + await exhaustMicrotasks(); + expect(repeater.start).toHaveBeenCalledTimes(1); + } + }); + + it('should flush the queue when the repeater triggers', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + await repeater.execute(0); + await exhaustMicrotasks(); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(1, config, events); + }); + + it('should reset the repeater after flush', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + expect(repeater.reset).not.toHaveBeenCalled(); + + await repeater.execute(0); + await exhaustMicrotasks(); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(1, config, events); + expect(repeater.reset).toHaveBeenCalledTimes(1); + }); + + it('should retry specified number of times with backoff if apiManager.sendEvents returns a rejecting promise', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockReturnValue(Promise.reject(new Error('Failed to dispatch events'))); + + const backoffController = { + backoff: vi.fn().mockReturnValue(666), + reset: vi.fn(), + }; + + const maxRetries = 5; + const retryConfig = { + maxRetries, + backoffProvider: () => backoffController, + }; + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: retryConfig, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + repeater.execute(0); + for(let i = 1; i <= maxRetries; i++) { + await exhaustMicrotasks(); + await advanceTimersByTime(666); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(i + 1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(i, config, events); + expect(backoffController.backoff).toHaveBeenCalledTimes(i); + } + }); + + it('should retry specified number of times with backoff if apiManager returns 5xx', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockReturnValue(Promise.resolve({ statusCode: 500 })); + + const backoffController = { + backoff: vi.fn().mockReturnValue(666), + reset: vi.fn(), + }; + + const maxRetries = 5; + const retryConfig = { + maxRetries, + backoffProvider: () => backoffController, + }; + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: retryConfig, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + repeater.execute(0); + for(let i = 1; i <= maxRetries; i++) { + await exhaustMicrotasks(); + await advanceTimersByTime(666); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(i + 1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(i, config, events); + expect(backoffController.backoff).toHaveBeenCalledTimes(i); + } + }); + + it('should log error if event sends fails even after retry', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockReturnValue(Promise.reject(new Error('Failed to dispatch events'))); + + const backoffController = { + backoff: vi.fn().mockReturnValue(666), + reset: vi.fn(), + }; + + const maxRetries = 5; + const retryConfig = { + maxRetries, + backoffProvider: () => backoffController, + }; + + const logger = getMockLogger(); + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: retryConfig, + }); + + odpEventManager.setLogger(logger); + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + repeater.execute(0); + for(let i = 1; i <= maxRetries; i++) { + await exhaustMicrotasks(); + await advanceTimersByTime(666); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(i + 1); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(i, config, events); + expect(backoffController.backoff).toHaveBeenCalledTimes(i); + } + + await exhaustMicrotasks(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('flushes the queue with old config if updateConfig is called with a new config', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + const newConfig = new OdpConfig('new-api-key', 'https://new-odp.example.com', 'https://new-odp.pixel.com', ['new-segment']); + odpEventManager.updateConfig({ + integrated: true, + odpConfig: newConfig, + }); + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).toHaveBeenCalledOnce(); + expect(apiManager.sendEvents).toHaveBeenCalledWith(config, events); + }); + + it('uses the new config after updateConfig is called', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + const newConfig = new OdpConfig('new-api-key', 'https://new-odp.example.com', 'https://new-odp.pixel.com', ['new-segment']); + odpEventManager.updateConfig({ + integrated: true, + odpConfig: newConfig, + }); + + const newEvents: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + newEvents.push(makeEvent(i + 10)); + odpEventManager.sendEvent(newEvents[i]); + } + + repeater.execute(0); + await exhaustMicrotasks(); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(2); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(1, config, events); + expect(apiManager.sendEvents).toHaveBeenNthCalledWith(2, newConfig, newEvents); + }); + + it('should reject onRunning() if stop() is called in new state', async () => { + const odpEventManager = new DefaultOdpEventManager({ + repeater: getMockRepeater(), + apiManager: getMockApiManager(), + batchSize: 10, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.stop(); + await expect(odpEventManager.onRunning()).rejects.toThrow(); + }); + + it('should flush the queue and reset the repeater if stop() is called in running state', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + const events: OdpEvent[] = []; + for(let i = 0; i < 10; i++) { + events.push(makeEvent(i)); + odpEventManager.sendEvent(events[i]); + } + + await exhaustMicrotasks(); + expect(apiManager.sendEvents).not.toHaveBeenCalled(); + + odpEventManager.stop(); + await exhaustMicrotasks(); + expect(apiManager.sendEvents).toHaveBeenCalledTimes(1); + expect(apiManager.sendEvents).toHaveBeenCalledWith(config, events); + expect(repeater.reset).toHaveBeenCalledTimes(1); + }); + + it('resolve onTerminated() and go to Terminated state if stop() is called in running state', async () => { + const repeater = getMockRepeater(); + + const apiManager = getMockApiManager(); + apiManager.sendEvents.mockResolvedValue({ statusCode: 200 }); + + const odpEventManager = new DefaultOdpEventManager({ + repeater: repeater, + apiManager: apiManager, + batchSize: 30, + retryConfig: { + maxRetries: 3, + backoffProvider: vi.fn(), + }, + }); + + odpEventManager.updateConfig({ + integrated: true, + odpConfig: config, + }); + + odpEventManager.start(); + await expect(odpEventManager.onRunning()).resolves.not.toThrow(); + + odpEventManager.stop(); + await expect(odpEventManager.onTerminated()).resolves.not.toThrow(); + expect(odpEventManager.getState()).toBe(ServiceState.Terminated); + }); +}); diff --git a/lib/odp/event_manager/odp_event_manager.ts b/lib/odp/event_manager/odp_event_manager.ts index 2b4d69e57..9db9086a4 100644 --- a/lib/odp/event_manager/odp_event_manager.ts +++ b/lib/odp/event_manager/odp_event_manager.ts @@ -14,440 +14,198 @@ * limitations under the License. */ -import { LogHandler, LogLevel } from '../../modules/logging'; - -import { uuid } from '../../utils/fns'; -import { ERROR_MESSAGES, ODP_USER_KEY, ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION } from '../../utils/enums'; - import { OdpEvent } from './odp_event'; -import { OdpConfig } from '../odp_config'; -import { IOdpEventApiManager } from './odp_event_api_manager'; -import { invalidOdpDataFound } from '../odp_utils'; -import { IUserAgentParser } from '../ua_parser/user_agent_parser'; -import { scheduleMicrotask } from '../../utils/microtask'; - -const MAX_RETRIES = 3; - -/** - * Event dispatcher's execution states - */ -export enum Status { - Stopped, - Running, +import { OdpConfig, OdpIntegrationConfig } from '../odp_config'; +import { OdpEventApiManager } from './odp_event_api_manager'; +import { BaseService, Service, ServiceState, StartupLog } from '../../service'; +import { BackoffController, Repeater } from '../../utils/repeater/repeater'; +import { Producer } from '../../utils/type'; +import { runWithRetry } from '../../utils/executor/backoff_retry_runner'; +import { isSuccessStatusCode } from '../../utils/http_request_handler/http_util'; +import { ERROR_MESSAGES } from '../../utils/enums'; +import { ODP_DEFAULT_EVENT_TYPE, ODP_USER_KEY } from '../constant'; + +export interface OdpEventManager extends Service { + updateConfig(odpIntegrationConfig: OdpIntegrationConfig): void; + sendEvent(event: OdpEvent): void; } -/** - * Manager for persisting events to the Optimizely Data Platform (ODP) - */ -export interface IOdpEventManager { - updateSettings(odpConfig: OdpConfig): void; - - start(): void; - - stop(): Promise; +export type RetryConfig = { + maxRetries: number; + backoffProvider: Producer; +} - registerVuid(vuid: string): void; +export type OdpEventManagerConfig = { + repeater: Repeater, + apiManager: OdpEventApiManager, + batchSize: number, + startUpLogs?: StartupLog[], + retryConfig: RetryConfig, +}; - identifyUser(userId?: string, vuid?: string): void; +export class DefaultOdpEventManager extends BaseService implements OdpEventManager { + private queue: OdpEvent[] = []; + private repeater: Repeater; + private odpIntegrationConfig?: OdpIntegrationConfig; + private apiManager: OdpEventApiManager; + private batchSize: number; - sendEvent(event: OdpEvent): void; + private retryConfig: RetryConfig; - flush(retry?: boolean): void; -} + constructor(config: OdpEventManagerConfig) { + super(config.startUpLogs); -/** - * Concrete implementation of a manager for persisting events to the Optimizely Data Platform - */ -export abstract class OdpEventManager implements IOdpEventManager { - /** - * Current state of the event processor - */ - status: Status = Status.Stopped; - - /** - * Queue for holding all events to be eventually dispatched - * @protected - */ - protected queue = new Array(); - - /** - * Identifier of the currently running timeout so clearCurrentTimeout() can be called - * @private - */ - private timeoutId?: NodeJS.Timeout | number; - - /** - * ODP configuration settings for identifying the target API and segments - * @private - */ - private odpConfig?: OdpConfig; - - /** - * REST API Manager used to send the events - * @private - */ - private readonly apiManager: IOdpEventApiManager; - - /** - * Handler for recording execution logs - * @private - */ - private readonly logger: LogHandler; - - /** - * Maximum queue size - * @protected - */ - protected queueSize!: number; - - /** - * Maximum number of events to process at once. Ignored in browser context - * @protected - */ - protected batchSize!: number; - - /** - * Milliseconds between setTimeout() to process new batches. Ignored in browser context - * @protected - */ - protected 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; - - /** - * Version of the client being used - * @private - */ - private readonly userAgentParser?: IUserAgentParser; - - private retries: number; - - - /** - * Information about the user agent - * @private - */ - private readonly userAgentData?: Map; - - constructor({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - queueSize, - batchSize, - flushInterval, - userAgentParser, - retries, - }: { - odpConfig?: OdpConfig; - apiManager: IOdpEventApiManager; - logger: LogHandler; - clientEngine: string; - clientVersion: string; - queueSize?: number; - batchSize?: number; - flushInterval?: number; - userAgentParser?: IUserAgentParser; - retries?: number; - }) { - this.apiManager = apiManager; - this.logger = logger; - this.clientEngine = clientEngine; - this.clientVersion = clientVersion; - this.initParams(batchSize, queueSize, flushInterval); - this.status = Status.Stopped; - this.userAgentParser = userAgentParser; - this.retries = retries || MAX_RETRIES; - - if (userAgentParser) { - const { os, device } = userAgentParser.parseUserAgentInfo(); - - const userAgentInfo: Record = { - 'os': os.name, - 'os_version': os.version, - 'device_type': device.type, - 'model': device.model, - }; - - this.userAgentData = new Map( - Object.entries(userAgentInfo).filter(([key, value]) => value != null && value != undefined) - ); - } + this.apiManager = config.apiManager; + this.batchSize = config.batchSize; + this.retryConfig = config.retryConfig; - if (odpConfig) { - this.updateSettings(odpConfig); - } + this.repeater = config.repeater; + this.repeater.setTask(() => this.flush()); } - protected abstract initParams( - batchSize: number | undefined, - queueSize: number | undefined, - flushInterval: number | undefined - ): void; - - /** - * Update ODP configuration settings. - * @param newConfig New configuration to apply - */ - updateSettings(odpConfig: OdpConfig): void { - // do nothing if config did not change - if (this.odpConfig && this.odpConfig.equals(odpConfig)) { - return; + private async executeDispatch(odpConfig: OdpConfig, batch: OdpEvent[]): Promise { + const res = await this.apiManager.sendEvents(odpConfig, batch); + if (res.statusCode && !isSuccessStatusCode(res.statusCode)) { + // TODO: replace message with imported constants + return Promise.reject(new Error(`Failed to dispatch events: ${res.statusCode}`)); } - - this.flush(); - this.odpConfig = odpConfig; + return await Promise.resolve(res); } - /** - * Cleans up all pending events; - */ - flush(): void { - this.processQueue(true); - } - - /** - * Start the event manager - */ - start(): void { - if (!this.odpConfig) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); + private async flush(): Promise { + if (!this.odpIntegrationConfig || !this.odpIntegrationConfig.integrated) { return; } - this.status = Status.Running; - - // no need of periodic flush if batchSize is 1 - if (this.batchSize > 1) { - this.setNewTimeout(); - } - } - - /** - * Drain the queue sending all remaining events in batches then stop processing - */ - async stop(): Promise { - this.logger.log(LogLevel.DEBUG, 'Stop requested.'); + const odpConfig = this.odpIntegrationConfig.odpConfig; - this.flush(); - this.clearCurrentTimeout(); - this.status = Status.Stopped; - this.logger.log(LogLevel.DEBUG, 'Stopped. Queue Count: %s', this.queue.length); - } + const batch = this.queue; + this.queue = []; - /** - * Register a new visitor user id (VUID) in ODP - * @param vuid Visitor User ID to send - */ - registerVuid(vuid: string): void { - const identifiers = new Map(); - identifiers.set(ODP_USER_KEY.VUID, vuid); + // as the queue has been emptied, stop repeating flush + // until more events become available + this.repeater.reset(); - const event = new OdpEvent(ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION.INITIALIZED, identifiers); - this.sendEvent(event); + return runWithRetry( + () => this.executeDispatch(odpConfig, batch), this.retryConfig.backoffProvider(), this.retryConfig.maxRetries + ).result.catch((err) => { + // TODO: replace with imported constants + this.logger?.error('failed to send odp events', err); + }); } - /** - * Associate a full-stack userid with an established VUID - * @param {string} userId (Optional) Full-stack User ID - * @param {string} vuid (Optional) Visitor User ID - */ - identifyUser(userId?: string, vuid?: string): void { - const identifiers = new Map(); - if (!userId && !vuid) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_SEND_EVENT_FAILED_UID_MISSING); + start(): void { + if (!this.isNew) { return; } - if (vuid) { - identifiers.set(ODP_USER_KEY.VUID, vuid); - } - - if (userId) { - identifiers.set(ODP_USER_KEY.FS_USER_ID, userId); - } - - const event = new OdpEvent(ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION.IDENTIFIED, identifiers); - this.sendEvent(event); - } - - /** - * Send an event to ODP via dispatch queue - * @param event ODP Event to forward - */ - sendEvent(event: OdpEvent): void { - if (invalidOdpDataFound(event.data)) { - this.logger.log(LogLevel.ERROR, 'Event data found to be invalid.'); + super.start(); + if (this.odpIntegrationConfig) { + this.goToRunningState(); } else { - event.data = this.augmentCommonData(event.data); - this.enqueue(event); + this.state = ServiceState.Starting; } } - /** - * Add a new event to the main queue - * @param event ODP Event to be queued - * @private - */ - private enqueue(event: OdpEvent): void { - if (this.status === Status.Stopped) { - this.logger.log(LogLevel.WARNING, 'Failed to Process ODP Event. ODPEventManager is not running.'); + updateConfig(odpIntegrationConfig: OdpIntegrationConfig): void { + if (this.isDone()) { return; } - if (!this.hasNecessaryIdentifiers(event)) { - this.logger.log(LogLevel.ERROR, 'ODP events should have at least one key-value pair in identifiers.'); + if (this.isNew()) { + this.odpIntegrationConfig = odpIntegrationConfig; 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 - ); + if (this.isStarting()) { + this.odpIntegrationConfig = odpIntegrationConfig; + this.goToRunningState(); return; } - this.queue.push(event); - this.processQueue(); + // already running, flush the queue using the previous config first before updating the config + this.flush(); + this.odpIntegrationConfig = odpIntegrationConfig; } - protected abstract hasNecessaryIdentifiers(event: OdpEvent): boolean; + private goToRunningState() { + this.state = ServiceState.Running; + this.startPromise.resolve(); + } - /** - * 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.status !== Status.Running) { + stop(): void { + if (this.isDone()) { return; } - - if (shouldFlush) { - // clear the queue completely - this.clearCurrentTimeout(); - while (this.queueContainsItems()) { - this.makeAndSend1Batch(); - } - } else if (this.queueHasBatches()) { - // Check if queue has a full batch available - this.clearCurrentTimeout(); - - while (this.queueHasBatches()) { - this.makeAndSend1Batch(); - } - } - - // no need for periodic flush if batchSize is 1 - if (this.batchSize > 1) { - this.setNewTimeout(); + if (this.isNew()) { + this.startPromise.reject(new Error('odp event manager stopped before it could start')); } - } - /** - * Clear the currently running timout - * @private - */ - private clearCurrentTimeout(): void { - clearTimeout(this.timeoutId); - this.timeoutId = undefined; + this.flush(); + this.state = ServiceState.Terminated; + this.stopPromise.resolve(); } - /** - * Start a new timeout - * @private - */ - private setNewTimeout(): void { - if (this.timeoutId !== undefined) { + sendEvent(event: OdpEvent): void { + if (!this.isRunning()) { + this.logger?.error('ODP event manager is not running.'); return; } - this.timeoutId = setTimeout(() => this.processQueue(true), this.flushInterval); - } - /** - * Make a batch and send it to ODP - * @private - */ - private makeAndSend1Batch(): void { - if (!this.odpConfig) { - return; + if (!this.odpIntegrationConfig?.integrated) { + this.logger?.error(ERROR_MESSAGES.ODP_NOT_INTEGRATED); + return; } - const batch = this.queue.splice(0, this.batchSize); - - const odpConfig = this.odpConfig; - - if (batch.length > 0) { - // put sending the event on another event loop - scheduleMicrotask(async () => { - let shouldRetry: boolean; - let attemptNumber = 0; - do { - shouldRetry = await this.apiManager.sendEvents(odpConfig, batch); - attemptNumber += 1; - } while (shouldRetry && attemptNumber < this.retries); - }) + if (event.identifiers.size === 0) { + this.logger?.error('ODP events should have at least one key-value pair in identifiers.'); + return; } - } - /** - * 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; - } + if (!this.isDataValid(event.data)) { + this.logger?.error('Event data found to be invalid.'); + return; + } - /** - * 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; - } + if (!event.action ) { + this.logger?.error('Event action invalid.'); + return; + } - protected abstract discardEventsIfNeeded(): void; + if (event.type === '') { + event.type = ODP_DEFAULT_EVENT_TYPE; + } - /** - * 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(this.userAgentData); + Array.from(event.identifiers.entries()).forEach(([key, value]) => { + // Catch for fs-user-id, FS-USER-ID, and FS_USER_ID and assign value to fs_user_id identifier. + if ( + ODP_USER_KEY.FS_USER_ID_ALIAS === key.toLowerCase() || + ODP_USER_KEY.FS_USER_ID === key.toLowerCase() + ) { + event.identifiers.delete(key); + event.identifiers.set(ODP_USER_KEY.FS_USER_ID, value); + } + }); - 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; + this.processEvent(event); } - protected getLogger(): LogHandler { - return this.logger; + private isDataValid(data: Map): boolean { + const validTypes: string[] = ['string', 'number', 'boolean']; + return Array.from(data.values()).reduce( + (valid, value) => valid && (value === null || validTypes.includes(typeof value)), + true, + ); } - getQueue(): OdpEvent[] { - return this.queue; + private processEvent(event: OdpEvent): void { + this.queue.push(event); + + if (this.queue.length === this.batchSize) { + this.flush(); + } else if (!this.repeater.isRunning()) { + this.repeater.start(); + } } } diff --git a/lib/odp/odp_manager.browser.ts b/lib/odp/odp_manager.browser.ts deleted file mode 100644 index 7168b5822..000000000 --- a/lib/odp/odp_manager.browser.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Copyright 2023-2024, 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 { - CLIENT_VERSION, - ERROR_MESSAGES, - JAVASCRIPT_CLIENT_ENGINE, - ODP_USER_KEY, - REQUEST_TIMEOUT_ODP_SEGMENTS_MS, - REQUEST_TIMEOUT_ODP_EVENTS_MS, - LOG_MESSAGES, -} from '../utils/enums'; -import { getLogger, LogHandler, LogLevel } from '../modules/logging'; - -import { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler'; - -import BrowserAsyncStorageCache from '../plugins/key_value_cache/browserAsyncStorageCache'; -import { BrowserLRUCache } from '../utils/lru_cache'; - -import { VuidManager } from '../plugins/vuid_manager/index'; - -import { OdpManager } from './odp_manager'; -import { OdpEvent } from './event_manager/odp_event'; -import { IOdpEventManager, OdpOptions } from '../shared_types'; -import { BrowserOdpEventApiManager } from './event_manager/event_api_manager.browser'; -import { BrowserOdpEventManager } from './event_manager/event_manager.browser'; -import { IOdpSegmentManager, OdpSegmentManager } from './segment_manager/odp_segment_manager'; -import { OdpSegmentApiManager } from './segment_manager/odp_segment_api_manager'; -import { OdpConfig, OdpIntegrationConfig } from './odp_config'; - -interface BrowserOdpManagerConfig { - clientEngine?: string, - clientVersion?: string, - logger?: LogHandler; - odpOptions?: OdpOptions; - odpIntegrationConfig?: OdpIntegrationConfig; -} - -// Client-side Browser Plugin for ODP Manager -export class BrowserOdpManager extends OdpManager { - static cache = new BrowserAsyncStorageCache(); - vuidManager?: VuidManager; - vuid?: string; - - constructor(options: { - odpIntegrationConfig?: OdpIntegrationConfig; - segmentManager: IOdpSegmentManager; - eventManager: IOdpEventManager; - logger: LogHandler; - }) { - super(options); - } - - static createInstance({ - logger, odpOptions, odpIntegrationConfig, clientEngine, clientVersion - }: BrowserOdpManagerConfig): BrowserOdpManager { - logger = logger || getLogger(); - - clientEngine = clientEngine || JAVASCRIPT_CLIENT_ENGINE; - clientVersion = clientVersion || CLIENT_VERSION; - - let odpConfig : OdpConfig | undefined = undefined; - if (odpIntegrationConfig?.integrated) { - odpConfig = odpIntegrationConfig.odpConfig; - } - - let customSegmentRequestHandler; - - if (odpOptions?.segmentsRequestHandler) { - customSegmentRequestHandler = odpOptions.segmentsRequestHandler; - } else { - customSegmentRequestHandler = new BrowserRequestHandler({ - logger, - timeout: odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS - }); - } - - let segmentManager: IOdpSegmentManager; - - if (odpOptions?.segmentManager) { - segmentManager = odpOptions.segmentManager; - } else { - segmentManager = new OdpSegmentManager( - odpOptions?.segmentsCache || - new BrowserLRUCache({ - maxSize: odpOptions?.segmentsCacheSize, - timeout: odpOptions?.segmentsCacheTimeout, - }), - new OdpSegmentApiManager(customSegmentRequestHandler, logger), - logger, - odpConfig - ); - } - - let customEventRequestHandler; - - if (odpOptions?.eventRequestHandler) { - customEventRequestHandler = odpOptions.eventRequestHandler; - } else { - customEventRequestHandler = new BrowserRequestHandler({ - logger, - timeout:odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS - }); - } - - let eventManager: IOdpEventManager; - - if (odpOptions?.eventManager) { - eventManager = odpOptions.eventManager; - } else { - eventManager = new BrowserOdpEventManager({ - odpConfig, - apiManager: new BrowserOdpEventApiManager(customEventRequestHandler, logger), - logger: logger, - clientEngine, - clientVersion, - flushInterval: odpOptions?.eventFlushInterval, - batchSize: odpOptions?.eventBatchSize, - queueSize: odpOptions?.eventQueueSize, - userAgentParser: odpOptions?.userAgentParser, - }); - } - - return new BrowserOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - }); - } - - /** - * @override - * accesses or creates new VUID from Browser cache - */ - protected async initializeVuid(): Promise { - const vuidManager = await VuidManager.instance(BrowserOdpManager.cache); - this.vuid = vuidManager.vuid; - } - - /** - * @override - * - Still identifies a user via the ODP Event Manager - * - Additionally, also passes VUID to help identify client-side users - * @param fsUserId Unique identifier of a target user. - */ - identifyUser(fsUserId?: string, vuid?: string): void { - if (fsUserId && VuidManager.isVuid(fsUserId)) { - super.identifyUser(undefined, fsUserId); - return; - } - - if (fsUserId && vuid && VuidManager.isVuid(vuid)) { - super.identifyUser(fsUserId, vuid); - return; - } - - super.identifyUser(fsUserId, vuid || this.vuid); - } - - /** - * @override - * - Sends an event to the ODP Server via the ODP Events API - * - Intercepts identifiers and injects VUID before sending event - * - Identifiers must contain at least one key-value pair - * @param {OdpEvent} odpEvent > ODP Event to send to event manager - */ - sendEvent({ type, action, identifiers, data }: OdpEvent): void { - const identifiersWithVuid = new Map(identifiers); - - if (!identifiers.has(ODP_USER_KEY.VUID)) { - if (this.vuid) { - identifiersWithVuid.set(ODP_USER_KEY.VUID, this.vuid); - } else { - throw new Error(ERROR_MESSAGES.ODP_SEND_EVENT_FAILED_VUID_MISSING); - } - } - - super.sendEvent({ type, action, identifiers: identifiersWithVuid, data }); - } - - isVuidEnabled(): boolean { - return true; - } - - getVuid(): string | undefined { - return this.vuid; - } -} diff --git a/lib/odp/odp_manager.node.ts b/lib/odp/odp_manager.node.ts deleted file mode 100644 index 648e27751..000000000 --- a/lib/odp/odp_manager.node.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright 2023-2024, 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 { NodeRequestHandler } from '../utils/http_request_handler/node_request_handler'; - -import { ServerLRUCache } from '../utils/lru_cache/server_lru_cache'; - -import { getLogger, LogHandler, LogLevel } from '../modules/logging'; -import { - NODE_CLIENT_ENGINE, - CLIENT_VERSION, - REQUEST_TIMEOUT_ODP_EVENTS_MS, - REQUEST_TIMEOUT_ODP_SEGMENTS_MS, -} from '../utils/enums'; - -import { OdpManager } from './odp_manager'; -import { IOdpEventManager, OdpOptions } from '../shared_types'; -import { NodeOdpEventApiManager } from './event_manager/event_api_manager.node'; -import { NodeOdpEventManager } from './event_manager/event_manager.node'; -import { IOdpSegmentManager, OdpSegmentManager } from './segment_manager/odp_segment_manager'; -import { OdpSegmentApiManager } from './segment_manager/odp_segment_api_manager'; -import { OdpConfig, OdpIntegrationConfig } from './odp_config'; - -interface NodeOdpManagerConfig { - clientEngine?: string, - clientVersion?: string, - logger?: LogHandler; - odpOptions?: OdpOptions; - odpIntegrationConfig?: OdpIntegrationConfig; -} - -/** - * Server-side Node Plugin for ODP Manager. - * Note: As this is still a work-in-progress. Please avoid using the Node ODP Manager. - */ -export class NodeOdpManager extends OdpManager { - constructor(options: { - odpIntegrationConfig?: OdpIntegrationConfig; - segmentManager: IOdpSegmentManager; - eventManager: IOdpEventManager; - logger: LogHandler; - }) { - super(options); - } - - static createInstance({ - logger, odpOptions, odpIntegrationConfig, clientEngine, clientVersion - }: NodeOdpManagerConfig): NodeOdpManager { - logger = logger || getLogger(); - - clientEngine = clientEngine || NODE_CLIENT_ENGINE; - clientVersion = clientVersion || CLIENT_VERSION; - - let odpConfig : OdpConfig | undefined = undefined; - if (odpIntegrationConfig?.integrated) { - odpConfig = odpIntegrationConfig.odpConfig; - } - - let customSegmentRequestHandler; - - if (odpOptions?.segmentsRequestHandler) { - customSegmentRequestHandler = odpOptions.segmentsRequestHandler; - } else { - customSegmentRequestHandler = new NodeRequestHandler({ - logger, - timeout: odpOptions?.segmentsApiTimeout || REQUEST_TIMEOUT_ODP_SEGMENTS_MS - }); - } - - let segmentManager: IOdpSegmentManager; - - if (odpOptions?.segmentManager) { - segmentManager = odpOptions.segmentManager; - } else { - segmentManager = new OdpSegmentManager( - odpOptions?.segmentsCache || - new ServerLRUCache({ - maxSize: odpOptions?.segmentsCacheSize, - timeout: odpOptions?.segmentsCacheTimeout, - }), - new OdpSegmentApiManager(customSegmentRequestHandler, logger), - logger, - odpConfig - ); - } - - let customEventRequestHandler; - - if (odpOptions?.eventRequestHandler) { - customEventRequestHandler = odpOptions.eventRequestHandler; - } else { - customEventRequestHandler = new NodeRequestHandler({ - logger, - timeout: odpOptions?.eventApiTimeout || REQUEST_TIMEOUT_ODP_EVENTS_MS - }); - } - - let eventManager: IOdpEventManager; - - if (odpOptions?.eventManager) { - eventManager = odpOptions.eventManager; - } else { - eventManager = new NodeOdpEventManager({ - odpConfig, - apiManager: new NodeOdpEventApiManager(customEventRequestHandler, logger), - logger: logger, - clientEngine, - clientVersion, - flushInterval: odpOptions?.eventFlushInterval, - batchSize: odpOptions?.eventBatchSize, - queueSize: odpOptions?.eventQueueSize, - userAgentParser: odpOptions?.userAgentParser, - }); - } - - return new NodeOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - }); - } - - public isVuidEnabled(): boolean { - return false; - } - - public getVuid(): string | undefined { - return undefined; - } -} diff --git a/lib/odp/odp_manager.spec.ts b/lib/odp/odp_manager.spec.ts new file mode 100644 index 000000000..2464bc28b --- /dev/null +++ b/lib/odp/odp_manager.spec.ts @@ -0,0 +1,699 @@ +/** + * Copyright 2023-2024, 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 { describe, it, vi, expect } from 'vitest'; + + +import { DefaultOdpManager } from './odp_manager'; +import { ServiceState } from '../service'; +import { resolvablePromise } from '../utils/promise/resolvablePromise'; +import { OdpConfig } from './odp_config'; +import { exhaustMicrotasks } from '../tests/testUtils'; +import { ODP_USER_KEY } from './constant'; +import { OptimizelySegmentOption } from './segment_manager/optimizely_segment_option'; +import { OdpEventManager } from './event_manager/odp_event_manager'; +import { CLIENT_VERSION, JAVASCRIPT_CLIENT_ENGINE } from '../utils/enums'; + +const keyA = 'key-a'; +const hostA = 'host-a'; +const pixelA = 'pixel-a'; +const segmentsA = ['a']; +const userA = 'fs-user-a'; + +const keyB = 'key-b'; +const hostB = 'host-b'; +const pixelB = 'pixel-b'; +const segmentsB = ['b']; +const userB = 'fs-user-b'; + +const config = new OdpConfig(keyA, hostA, pixelA, segmentsA); +const updatedConfig = new OdpConfig(keyB, hostB, pixelB, segmentsB); + +const getMockOdpEventManager = () => { + return { + start: vi.fn(), + stop: vi.fn(), + onRunning: vi.fn(), + onTerminated: vi.fn(), + getState: vi.fn(), + updateConfig: vi.fn(), + sendEvent: vi.fn(), + }; +}; + +const getMockOdpSegmentManager = () => { + return { + fetchQualifiedSegments: vi.fn(), + updateConfig: vi.fn(), + }; +}; + +describe('DefaultOdpManager', () => { + it('should be in new state on construction', () => { + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager: getMockOdpEventManager(), + }); + + expect(odpManager.getState()).toEqual(ServiceState.New); + }); + + it('should be in starting state after start is called', () => { + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager: getMockOdpEventManager(), + }); + + odpManager.start(); + + expect(odpManager.getState()).toEqual(ServiceState.Starting); + }); + + it('should start eventManager after start is called', () => { + const eventManager = getMockOdpEventManager(); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + expect(eventManager.start).toHaveBeenCalled(); + }); + + it('should stay in starting state if updateConfig is called but eventManager is still not running', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(resolvablePromise().promise); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await exhaustMicrotasks(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + }); + + it('should stay in starting state if eventManager is running but config is not yet available', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + + await exhaustMicrotasks(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + }); + + it('should go to running state and resolve onRunning() if updateConfig is called and eventManager is running', async () => { + const eventManager = getMockOdpEventManager(); + const eventManagerPromise = resolvablePromise(); + eventManager.onRunning.mockReturnValue(eventManagerPromise.promise); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await exhaustMicrotasks(); + + expect(odpManager.getState()).toEqual(ServiceState.Starting); + eventManagerPromise.resolve(); + + await expect(odpManager.onRunning()).resolves.not.toThrow(); + expect(odpManager.getState()).toEqual(ServiceState.Running); + }); + + it('should go to failed state and reject onRunning(), onTerminated() if updateConfig is called and eventManager fails to start', async () => { + const eventManager = getMockOdpEventManager(); + const eventManagerPromise = resolvablePromise(); + eventManager.onRunning.mockReturnValue(eventManagerPromise.promise); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await exhaustMicrotasks(); + + expect(odpManager.getState()).toEqual(ServiceState.Starting); + eventManagerPromise.reject(new Error('Failed to start')); + + await expect(odpManager.onRunning()).rejects.toThrow(); + await expect(odpManager.onTerminated()).rejects.toThrow(); + expect(odpManager.getState()).toEqual(ServiceState.Failed); + }); + + it('should go to failed state and reject onRunning(), onTerminated() if eventManager fails to start before updateSettings()', async () => { + const eventManager = getMockOdpEventManager(); + const eventManagerPromise = resolvablePromise(); + eventManager.onRunning.mockReturnValue(eventManagerPromise.promise); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + + eventManagerPromise.reject(new Error('Failed to start')); + + await expect(odpManager.onRunning()).rejects.toThrow(); + await expect(odpManager.onTerminated()).rejects.toThrow(); + expect(odpManager.getState()).toEqual(ServiceState.Failed); + }); + + it('should pass the changed config to eventManager and segmentManager', async () => { + const eventManager = getMockOdpEventManager(); + const segmentManager = getMockOdpSegmentManager(); + + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const odpManager = new DefaultOdpManager({ + segmentManager, + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + expect(eventManager.updateConfig).toHaveBeenNthCalledWith(1, { integrated: true, odpConfig: config }); + expect(segmentManager.updateConfig).toHaveBeenNthCalledWith(1, { integrated: true, odpConfig: config }); + + odpManager.updateConfig({ integrated: true, odpConfig: updatedConfig }); + + expect(eventManager.updateConfig).toHaveBeenNthCalledWith(2, { integrated: true, odpConfig: updatedConfig }); + expect(segmentManager.updateConfig).toHaveBeenNthCalledWith(2, { integrated: true, odpConfig: updatedConfig }); + expect(eventManager.updateConfig).toHaveBeenCalledTimes(2); + expect(segmentManager.updateConfig).toHaveBeenCalledTimes(2); + }); + + it('should not call eventManager and segmentManager updateConfig if config does not change', async () => { + const eventManager = getMockOdpEventManager(); + const segmentManager = getMockOdpSegmentManager(); + + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const odpManager = new DefaultOdpManager({ + segmentManager, + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + expect(eventManager.updateConfig).toHaveBeenNthCalledWith(1, { integrated: true, odpConfig: config }); + expect(segmentManager.updateConfig).toHaveBeenNthCalledWith(1, { integrated: true, odpConfig: config }); + + odpManager.updateConfig({ integrated: true, odpConfig: JSON.parse(JSON.stringify(config)) }); + + expect(eventManager.updateConfig).toHaveBeenCalledTimes(1); + expect(segmentManager.updateConfig).toHaveBeenCalledTimes(1); + }); + + it('fetches qualified segments correctly for both fs_user_id and vuid from segmentManager', async () => { + const segmentManager = getMockOdpSegmentManager(); + segmentManager.fetchQualifiedSegments.mockImplementation((key: ODP_USER_KEY) => { + if (key === ODP_USER_KEY.FS_USER_ID) { + return Promise.resolve(['fs1', 'fs2']); + } + return Promise.resolve(['vuid1', 'vuid2']); + }); + + const odpManager = new DefaultOdpManager({ + segmentManager, + eventManager: getMockOdpEventManager(), + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + const fsSegments = await odpManager.fetchQualifiedSegments(userA); + expect(fsSegments).toEqual(['fs1', 'fs2']); + expect(segmentManager.fetchQualifiedSegments).toHaveBeenNthCalledWith(1, ODP_USER_KEY.FS_USER_ID, userA, []); + + const vuidSegments = await odpManager.fetchQualifiedSegments('vuid_abcd'); + expect(vuidSegments).toEqual(['vuid1', 'vuid2']); + expect(segmentManager.fetchQualifiedSegments).toHaveBeenNthCalledWith(2, ODP_USER_KEY.VUID, 'vuid_abcd', []); + }); + + it('returns null from fetchQualifiedSegments if segmentManger returns null', async () => { + const segmentManager = getMockOdpSegmentManager(); + segmentManager.fetchQualifiedSegments.mockResolvedValue(null); + + const odpManager = new DefaultOdpManager({ + segmentManager, + eventManager: getMockOdpEventManager(), + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + const fsSegments = await odpManager.fetchQualifiedSegments(userA); + expect(fsSegments).toBeNull(); + + const vuidSegments = await odpManager.fetchQualifiedSegments('vuid_abcd'); + expect(vuidSegments).toBeNull(); + }); + + it('passes options to segmentManager correctly', async () => { + const segmentManager = getMockOdpSegmentManager(); + segmentManager.fetchQualifiedSegments.mockResolvedValue(null); + + const odpManager = new DefaultOdpManager({ + segmentManager, + eventManager: getMockOdpEventManager(), + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + const options = [OptimizelySegmentOption.IGNORE_CACHE, OptimizelySegmentOption.RESET_CACHE]; + await odpManager.fetchQualifiedSegments(userA, options); + expect(segmentManager.fetchQualifiedSegments).toHaveBeenNthCalledWith(1, ODP_USER_KEY.FS_USER_ID, userA, options); + + await odpManager.fetchQualifiedSegments('vuid_abcd', options); + expect(segmentManager.fetchQualifiedSegments).toHaveBeenNthCalledWith(2, ODP_USER_KEY.VUID, 'vuid_abcd', options); + + await odpManager.fetchQualifiedSegments(userA, [OptimizelySegmentOption.IGNORE_CACHE]); + expect(segmentManager.fetchQualifiedSegments).toHaveBeenNthCalledWith( + 3, ODP_USER_KEY.FS_USER_ID, userA, [OptimizelySegmentOption.IGNORE_CACHE]); + + await odpManager.fetchQualifiedSegments('vuid_abcd', []); + expect(segmentManager.fetchQualifiedSegments).toHaveBeenNthCalledWith(4, ODP_USER_KEY.VUID, 'vuid_abcd', []); + }); + + it('sends a client_intialized event with the vuid after becoming ready if setVuid is called and odp is integrated', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.setVuid('vuid_123'); + + await exhaustMicrotasks(); + expect(eventManager.sendEvent).not.toHaveBeenCalled(); + + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + expect(mockSendEvents).toHaveBeenCalledOnce(); + + const { type, action, identifiers } = mockSendEvents.mock.calls[0][0]; + expect(type).toEqual('fullstack'); + expect(action).toEqual('client_initialized'); + expect(identifiers).toEqual(new Map([['vuid', 'vuid_123']])); + }); + + it('does not send a client_intialized event with the vuid after becoming ready if setVuid is called and odp is not integrated', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.setVuid('vuid_123'); + + await exhaustMicrotasks(); + expect(eventManager.sendEvent).not.toHaveBeenCalled(); + + odpManager.updateConfig({ integrated: false }); + await odpManager.onRunning(); + + await exhaustMicrotasks(); + expect(mockSendEvents).not.toHaveBeenCalled(); + }); + + it('includes the available vuid in events sent via sendEvent', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + odpManager.setVuid('vuid_123'); + + const event = { + type: 'type', + action: 'action', + identifiers: new Map([['email', 'a@b.com']]), + data: new Map([['key1', 'value1'], ['key2', 'value2']]), + }; + + odpManager.sendEvent(event); + const { identifiers } = mockSendEvents.mock.calls[0][0]; + expect(identifiers).toEqual(new Map([['email', 'a@b.com'], ['vuid', 'vuid_123']])); + }); + + it('does not override the vuid in events sent via sendEvent', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + odpManager.setVuid('vuid_123'); + + const event = { + type: 'type', + action: 'action', + identifiers: new Map([['email', 'a@b.com'], ['vuid', 'vuid_456']]), + data: new Map([['key1', 'value1'], ['key2', 'value2']]), + }; + + odpManager.sendEvent(event); + const { identifiers } = mockSendEvents.mock.calls[0][0]; + expect(identifiers).toEqual(new Map([['email', 'a@b.com'], ['vuid', 'vuid_456']])); + }); + + it('augments the data with common data before sending the event', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + const event = { + type: 'type', + action: 'action', + identifiers: new Map([['email', 'a@b.com']]), + data: new Map([['key1', 'value1'], ['key2', 'value2']]), + }; + + odpManager.sendEvent(event); + const { data } = mockSendEvents.mock.calls[0][0]; + expect(data.get('idempotence_id')).toBeDefined(); + expect(data.get('data_source_type')).toEqual('sdk'); + expect(data.get('data_source')).toEqual(JAVASCRIPT_CLIENT_ENGINE); + expect(data.get('data_source_version')).toEqual(CLIENT_VERSION); + expect(data.get('key1')).toEqual('value1'); + expect(data.get('key2')).toEqual('value2'); + }); + + it('uses the clientInfo provided by setClientInfo() when augmenting the data', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + odpManager.setClientInfo('client', 'version'); + + const event = { + type: 'type', + action: 'action', + identifiers: new Map([['email', 'a@b.com']]), + data: new Map([['key1', 'value1'], ['key2', 'value2']]), + }; + + odpManager.sendEvent(event); + const { data } = mockSendEvents.mock.calls[0][0]; + expect(data.get('data_source')).toEqual('client'); + expect(data.get('data_source_version')).toEqual('version'); + }); + + it('augments the data with user agent data before sending the event if userAgentParser is provided ', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + userAgentParser: { + parseUserAgentInfo: () => ({ + os: { name: 'os', version: '1.0' }, + device: { type: 'phone', model: 'model' }, + }), + }, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + const event = { + type: 'type', + action: 'action', + identifiers: new Map([['email', 'a@b.com']]), + data: new Map([['key1', 'value1'], ['key2', 'value2']]), + }; + + odpManager.sendEvent(event); + const { data } = mockSendEvents.mock.calls[0][0]; + expect(data.get('os')).toEqual('os'); + expect(data.get('os_version')).toEqual('1.0'); + expect(data.get('device_type')).toEqual('phone'); + expect(data.get('model')).toEqual('model'); + }); + + it('sends identified event with both fs_user_id and vuid if both parameters are provided', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + odpManager.identifyUser('user', 'vuid_a'); + expect(mockSendEvents).toHaveBeenCalledOnce(); + const { identifiers } = mockSendEvents.mock.calls[0][0]; + expect(identifiers).toEqual(new Map([['fs_user_id', 'user'], ['vuid', 'vuid_a']])); + }); + + it('sends identified event when called with just fs_user_id in first parameter', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + odpManager.identifyUser('user'); + expect(mockSendEvents).toHaveBeenCalledOnce(); + const { identifiers } = mockSendEvents.mock.calls[0][0]; + expect(identifiers).toEqual(new Map([['fs_user_id', 'user']])); + }); + + it('sends identified event when called with just vuid in first parameter', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + + const mockSendEvents = vi.mocked(eventManager.sendEvent as OdpEventManager['sendEvent']); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.updateConfig({ integrated: true, odpConfig: config }); + await odpManager.onRunning(); + + odpManager.identifyUser('vuid_a'); + expect(mockSendEvents).toHaveBeenCalledOnce(); + const { identifiers } = mockSendEvents.mock.calls[0][0]; + expect(identifiers).toEqual(new Map([['vuid', 'vuid_a']])); + }); + + it('should reject onRunning() if stopped in new state', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + eventManager.onTerminated.mockReturnValue(Promise.resolve()); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.stop(); + + await expect(odpManager.onRunning()).rejects.toThrow(); + }); + + it('should reject onRunning() if stopped in starting state', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + eventManager.onTerminated.mockReturnValue(Promise.resolve()); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + expect(odpManager.getState()).toEqual(ServiceState.Starting); + + odpManager.stop(); + await expect(odpManager.onRunning()).rejects.toThrow(); + }); + + it('should go to stopping state and wait for eventManager to stop if stop is called', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + eventManager.onTerminated.mockReturnValue(resolvablePromise().promise); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.stop(); + + const terminatedHandler = vi.fn(); + odpManager.onTerminated().then(terminatedHandler); + + expect(odpManager.getState()).toEqual(ServiceState.Stopping); + await exhaustMicrotasks(); + expect(terminatedHandler).not.toHaveBeenCalled(); + }); + + it('should stop eventManager if stop is called', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + eventManager.onTerminated.mockReturnValue(Promise.resolve()); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + + odpManager.stop(); + expect(eventManager.stop).toHaveBeenCalled(); + }); + + it('should resolve onTerminated after eventManager stops successfully', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + const eventManagerTerminatedPromise = resolvablePromise(); + eventManager.onTerminated.mockReturnValue(eventManagerTerminatedPromise.promise); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.stop(); + await exhaustMicrotasks(); + expect(odpManager.getState()).toEqual(ServiceState.Stopping); + + eventManagerTerminatedPromise.resolve(); + await expect(odpManager.onTerminated()).resolves.not.toThrow(); + }); + + it('should reject onTerminated after eventManager fails to stop correctly', async () => { + const eventManager = getMockOdpEventManager(); + eventManager.onRunning.mockReturnValue(Promise.resolve()); + const eventManagerTerminatedPromise = resolvablePromise(); + eventManager.onTerminated.mockReturnValue(eventManagerTerminatedPromise.promise); + + const odpManager = new DefaultOdpManager({ + segmentManager: getMockOdpSegmentManager(), + eventManager, + }); + + odpManager.start(); + odpManager.stop(); + await exhaustMicrotasks(); + expect(odpManager.getState()).toEqual(ServiceState.Stopping); + + eventManagerTerminatedPromise.reject(new Error('Failed to stop')); + await expect(odpManager.onTerminated()).rejects.toThrow(); + }); +}); + diff --git a/lib/odp/odp_manager.ts b/lib/odp/odp_manager.ts index df2bbc394..560e445a4 100644 --- a/lib/odp/odp_manager.ts +++ b/lib/odp/odp_manager.ts @@ -14,190 +14,157 @@ * limitations under the License. */ -import { LogHandler, LogLevel } from '../modules/logging'; -import { ERROR_MESSAGES, ODP_USER_KEY } from '../utils/enums'; - -import { VuidManager } from '../plugins/vuid_manager'; +import { v4 as uuidV4} from 'uuid'; +import { LoggerFacade } from '../modules/logging'; import { OdpIntegrationConfig, odpIntegrationsAreEqual } from './odp_config'; -import { IOdpEventManager } from './event_manager/odp_event_manager'; -import { IOdpSegmentManager } from './segment_manager/odp_segment_manager'; +import { OdpEventManager } from './event_manager/odp_event_manager'; +import { OdpSegmentManager } from './segment_manager/odp_segment_manager'; import { OptimizelySegmentOption } from './segment_manager/optimizely_segment_option'; -import { invalidOdpDataFound } from './odp_utils'; import { OdpEvent } from './event_manager/odp_event'; import { resolvablePromise, ResolvablePromise } from '../utils/promise/resolvablePromise'; - -/** - * Manager for handling internal all business logic related to - * Optimizely Data Platform (ODP) / Advanced Audience Targeting (AAT) - */ -export interface IOdpManager { - onReady(): Promise; - - isReady(): boolean; - - updateSettings(odpIntegrationConfig: OdpIntegrationConfig): boolean; - - stop(): void; - +import { BaseService, Service, ServiceState } from '../service'; +import { UserAgentParser } from './ua_parser/user_agent_parser'; +import { CLIENT_VERSION, JAVASCRIPT_CLIENT_ENGINE } from '../utils/enums'; +import { ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION, ODP_USER_KEY } from './constant'; +import { isVuid } from '../vuid/vuid'; +import { Maybe } from '../utils/type'; + +export interface OdpManager extends Service { + updateConfig(odpIntegrationConfig: OdpIntegrationConfig): boolean; fetchQualifiedSegments(userId: string, options?: Array): Promise; - - identifyUser(userId?: string, vuid?: string): void; - - sendEvent({ type, action, identifiers, data }: OdpEvent): void; - - isVuidEnabled(): boolean; - - getVuid(): string | undefined; -} - -export enum Status { - Running, - Stopped, + identifyUser(userId: string, vuid?: string): void; + sendEvent(event: OdpEvent): void; + setClientInfo(clientEngine: string, clientVersion: string): void; + setVuid(vuid: string): void; } -/** - * Orchestrates segments manager, event manager, and ODP configuration - */ -export abstract class OdpManager implements IOdpManager { - /** - * Promise that returns when the OdpManager is finished initializing - */ - private initPromise: Promise; - private ready = false; +export type OdpManagerConfig = { + segmentManager: OdpSegmentManager; + eventManager: OdpEventManager; + logger?: LoggerFacade; + userAgentParser?: UserAgentParser; +}; - /** - * Promise that resolves when odpConfig becomes available - */ +export class DefaultOdpManager extends BaseService implements OdpManager { private configPromise: ResolvablePromise; - - status: Status = Status.Stopped; - - /** - * ODP Segment Manager which provides an interface to the remote ODP server (GraphQL API) for audience segments mapping. - * It fetches all qualified segments for the given user context and manages the segments cache for all user contexts. - */ - private segmentManager: IOdpSegmentManager; - - /** - * ODP Event Manager which provides an interface to the remote ODP server (REST API) for events. - * It will queue all pending events (persistent) and send them (in batches of up to 10 events) to the ODP server when possible. - */ - private eventManager: IOdpEventManager; - - /** - * Handler for recording execution logs - * @protected - */ - protected logger: LogHandler; - - /** - * ODP configuration settings for identifying the target API and segments - */ - odpIntegrationConfig?: OdpIntegrationConfig; - - // TODO: Consider accepting logger as a parameter and initializing it in constructor instead - constructor({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - }: { - odpIntegrationConfig?: OdpIntegrationConfig; - segmentManager: IOdpSegmentManager; - eventManager: IOdpEventManager; - logger: LogHandler; - }) { - this.segmentManager = segmentManager; - this.eventManager = eventManager; - this.logger = logger; + private segmentManager: OdpSegmentManager; + private eventManager: OdpEventManager; + private odpIntegrationConfig?: OdpIntegrationConfig; + private vuid?: string; + private clientEngine = JAVASCRIPT_CLIENT_ENGINE; + private clientVersion = CLIENT_VERSION; + private userAgentData?: Map; + + constructor(config: OdpManagerConfig) { + super(); + this.segmentManager = config.segmentManager; + this.eventManager = config.eventManager; + this.logger = config.logger; this.configPromise = resolvablePromise(); - const readinessDependencies: PromiseLike[] = [this.configPromise]; + if (config.userAgentParser) { + const { os, device } = config.userAgentParser.parseUserAgentInfo(); - if (this.isVuidEnabled()) { - readinessDependencies.push(this.initializeVuid()); - } - - this.initPromise = Promise.all(readinessDependencies); - - this.onReady().then(() => { - this.ready = true; - if (this.isVuidEnabled() && this.status === Status.Running) { - this.registerVuid(); - } - }); + const userAgentInfo: Record = { + 'os': os.name, + 'os_version': os.version, + 'device_type': device.type, + 'model': device.model, + }; - if (odpIntegrationConfig) { - this.updateSettings(odpIntegrationConfig); + this.userAgentData = new Map( + Object.entries(userAgentInfo).filter(([_, value]) => value != null && value != undefined) + ); } } - public getStatus(): Status { - return this.status; + setClientInfo(clientEngine: string, clientVersion: string): void { + this.clientEngine = clientEngine; + this.clientVersion = clientVersion; } - async start(): Promise { - if (this.status === Status.Running) { + start(): void { + if (!this.isNew()) { return; } - if (!this.odpIntegrationConfig) { - return Promise.reject(new Error('cannot start without ODP config')); - } + this.state = ServiceState.Starting; - if (!this.odpIntegrationConfig.integrated) { - return Promise.reject(new Error('start() called when ODP is not integrated')); - } - - this.status = Status.Running; - this.segmentManager.updateSettings(this.odpIntegrationConfig.odpConfig); - this.eventManager.updateSettings(this.odpIntegrationConfig.odpConfig); this.eventManager.start(); - return Promise.resolve(); + + const startDependencies = [ + this.configPromise, + this.eventManager.onRunning(), + ]; + + Promise.all(startDependencies) + .then(() => { + this.handleStartSuccess(); + }).catch((err) => { + this.handleStartFailure(err); + }); } - async stop(): Promise { - if (this.status === Status.Stopped) { + private handleStartSuccess() { + if (this.isDone()) { return; } - this.status = Status.Stopped; - await this.eventManager.stop(); + this.state = ServiceState.Running; + this.startPromise.resolve(); } - onReady(): Promise { - return this.initPromise; - } + private handleStartFailure(error: Error) { + if (this.isDone()) { + return; + } - isReady(): boolean { - return this.ready; + this.state = ServiceState.Failed; + this.startPromise.reject(error); + this.stopPromise.reject(error); } - /** - * Provides a method to update ODP Manager's ODP Config - */ - updateSettings(odpIntegrationConfig: OdpIntegrationConfig): boolean { - this.configPromise.resolve(); + stop(): void { + if (this.isDone()) { + return; + } + + if (!this.isRunning()) { + this.startPromise.reject(new Error('odp manager stopped before running')); + } + this.state = ServiceState.Stopping; + this.eventManager.stop(); + + this.eventManager.onTerminated().then(() => { + this.state = ServiceState.Terminated; + this.stopPromise.resolve(); + }).catch((err) => { + this.state = ServiceState.Failed; + this.stopPromise.reject(err); + }); + } + + updateConfig(odpIntegrationConfig: OdpIntegrationConfig): boolean { // do nothing if config did not change if (this.odpIntegrationConfig && odpIntegrationsAreEqual(this.odpIntegrationConfig, odpIntegrationConfig)) { return false; } + if (this.isDone()) { + return false; + } + this.odpIntegrationConfig = odpIntegrationConfig; - if (odpIntegrationConfig.integrated) { - // already running, just propagate updated config to children; - if (this.status === Status.Running) { - this.segmentManager.updateSettings(odpIntegrationConfig.odpConfig); - this.eventManager.updateSettings(odpIntegrationConfig.odpConfig); - } else { - this.start(); - } - } else { - this.stop(); + if (this.isStarting()) { + this.configPromise.resolve(); } + + this.segmentManager.updateConfig(odpIntegrationConfig) + this.eventManager.updateConfig(odpIntegrationConfig); + return true; } @@ -209,114 +176,64 @@ export abstract class OdpManager implements IOdpManager { * @returns {Promise} A promise holding either a list of qualified segments or null. */ async fetchQualifiedSegments(userId: string, options: Array = []): Promise { - if (!this.odpIntegrationConfig) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); - return null; - } - - if (!this.odpIntegrationConfig.integrated) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED); - return null; - } - - if (VuidManager.isVuid(userId)) { + if (isVuid(userId)) { return this.segmentManager.fetchQualifiedSegments(ODP_USER_KEY.VUID, userId, options); } return this.segmentManager.fetchQualifiedSegments(ODP_USER_KEY.FS_USER_ID, userId, options); } - /** - * Identifies a user via the ODP Event Manager - * @param {string} userId (Optional) Custom unique identifier of a target user. - * @param {string} vuid (Optional) Secondary unique identifier of a target user, primarily used by client SDKs. - * @returns - */ - identifyUser(userId?: string, vuid?: string): void { - if (!this.odpIntegrationConfig) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); - return; - } - - if (!this.odpIntegrationConfig.integrated) { - this.logger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_INTEGRATED); - return; - } - - if (userId && VuidManager.isVuid(userId)) { - this.eventManager.identifyUser(undefined, userId); - return; - } - - this.eventManager.identifyUser(userId, vuid); - } - - /** - * Sends an event to the ODP Server via the ODP Events API - * @param {OdpEvent} > ODP Event to send to event manager - */ - sendEvent({ type, action, identifiers, data }: OdpEvent): void { - let mType = type; + identifyUser(userId: string, vuid?: string): void { + const identifiers = new Map(); + + let finalUserId: Maybe = userId; + let finalVuid: Maybe = vuid; - if (typeof mType !== 'string' || mType === '') { - mType = 'fullstack'; + if (!vuid && isVuid(userId)) { + finalVuid = userId; + finalUserId = undefined; } - if (!this.odpIntegrationConfig) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); - return; + if (finalVuid) { + identifiers.set(ODP_USER_KEY.VUID, finalVuid); } - if (!this.odpIntegrationConfig.integrated) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED); - return; + if (finalUserId) { + identifiers.set(ODP_USER_KEY.FS_USER_ID, finalUserId); } - if (invalidOdpDataFound(data)) { - throw new Error(ERROR_MESSAGES.ODP_INVALID_DATA); - } + const event = new OdpEvent(ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION.IDENTIFIED, identifiers); + this.sendEvent(event); + } - if (typeof action !== 'string' || action === '') { - throw new Error('ODP action is not valid (cannot be empty).'); + sendEvent(event: OdpEvent): void { + if (!event.identifiers.has(ODP_USER_KEY.VUID) && this.vuid) { + event.identifiers.set(ODP_USER_KEY.VUID, this.vuid); } - this.eventManager.sendEvent(new OdpEvent(mType, action, identifiers, data)); + event.data = this.augmentCommonData(event.data); + this.eventManager.sendEvent(event); } - /** - * Identifies if the VUID feature is enabled - */ - abstract isVuidEnabled(): boolean; - - /** - * Returns VUID value if it exists - */ - abstract getVuid(): string | undefined; + private augmentCommonData(sourceData: Map): Map { + const data = new Map(this.userAgentData); + + data.set('idempotence_id', uuidV4()); + data.set('data_source_type', 'sdk'); + data.set('data_source', this.clientEngine); + data.set('data_source_version', this.clientVersion); - protected initializeVuid(): Promise { - return Promise.resolve(); + sourceData.forEach((value, key) => data.set(key, value)); + return data; } - private registerVuid() { - if (!this.odpIntegrationConfig) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); - return; - } - - if (!this.odpIntegrationConfig.integrated) { - this.logger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_INTEGRATED); - return; - } - - const vuid = this.getVuid(); - if (!vuid) { - return; - } - - try { - this.eventManager.registerVuid(vuid); - } catch (e) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_VUID_REGISTRATION_FAILED); - } + setVuid(vuid: string): void { + this.vuid = vuid; + this.onRunning().then(() => { + if (this.odpIntegrationConfig?.integrated) { + const event = new OdpEvent(ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION.INITIALIZED); + this.sendEvent(event); + } + }); } } diff --git a/lib/odp/odp_manager_factory.browser.spec.ts b/lib/odp/odp_manager_factory.browser.spec.ts new file mode 100644 index 000000000..333856743 --- /dev/null +++ b/lib/odp/odp_manager_factory.browser.spec.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2024, 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. + */ + +vi.mock('../utils/http_request_handler/browser_request_handler', () => { + return { BrowserRequestHandler: vi.fn() }; +}); + +vi.mock('./odp_manager_factory', () => { + return { getOdpManager: vi.fn().mockImplementation(() => ({})) }; +}); + + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { getOdpManager, OdpManagerOptions } from './odp_manager_factory'; +import { BROWSER_DEFAULT_API_TIMEOUT, createOdpManager } from './odp_manager_factory.browser'; +import { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler'; +import { pixelApiRequestGenerator } from './event_manager/odp_event_api_manager'; + +describe('createOdpManager', () => { + const MockBrowserRequestHandler = vi.mocked(BrowserRequestHandler); + const mockGetOdpManager = vi.mocked(getOdpManager); + + beforeEach(() => { + MockBrowserRequestHandler.mockClear(); + mockGetOdpManager.mockClear(); + }); + + it('should use BrowserRequestHandler with the provided timeout as the segment request handler', () => { + const odpManager = createOdpManager({ segmentsApiTimeout: 3456 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { segmentRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(segmentRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[0]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[0][0]; + expect(requestHandlerOptions?.timeout).toBe(3456); + }); + + it('should use BrowserRequestHandler with the browser default timeout as the segment request handler', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { segmentRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(segmentRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[0]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[0][0]; + expect(requestHandlerOptions?.timeout).toBe(BROWSER_DEFAULT_API_TIMEOUT); + }); + + it('should use BrowserRequestHandler with the provided timeout as the event request handler', () => { + const odpManager = createOdpManager({ eventApiTimeout: 2345 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[1]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[1][0]; + expect(requestHandlerOptions?.timeout).toBe(2345); + }); + + it('should use BrowserRequestHandler with the browser default timeout as the event request handler', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[1]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[1][0]; + expect(requestHandlerOptions?.timeout).toBe(BROWSER_DEFAULT_API_TIMEOUT); + }); + + it('should use batchSize 1 if batchSize is not provided', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventBatchSize } = mockGetOdpManager.mock.calls[0][0]; + expect(eventBatchSize).toBe(1); + }); + + it('should use batchSize 1 event if some other batchSize value is provided', () => { + const odpManager = createOdpManager({ eventBatchSize: 99 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventBatchSize } = mockGetOdpManager.mock.calls[0][0]; + expect(eventBatchSize).toBe(1); + }); + + it('uses the pixel api request generator', () => { + const odpManager = createOdpManager({ }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestGenerator } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestGenerator).toBe(pixelApiRequestGenerator); + }); + + it('uses the passed options for relevant fields', () => { + const options: OdpManagerOptions = { + segmentsCache: {} as any, + segmentsCacheSize: 11, + segmentsCacheTimeout: 2025, + segmentManager: {} as any, + eventFlushInterval: 2222, + eventManager: {} as any, + userAgentParser: {} as any, + }; + const odpManager = createOdpManager(options); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + expect(mockGetOdpManager).toHaveBeenNthCalledWith(1, expect.objectContaining(options)); + }); +}); diff --git a/lib/odp/odp_manager_factory.browser.ts b/lib/odp/odp_manager_factory.browser.ts new file mode 100644 index 000000000..481252278 --- /dev/null +++ b/lib/odp/odp_manager_factory.browser.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2024, 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 { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler'; +import { pixelApiRequestGenerator } from './event_manager/odp_event_api_manager'; +import { OdpManager } from './odp_manager'; +import { getOdpManager, OdpManagerOptions } from './odp_manager_factory'; + +export const BROWSER_DEFAULT_API_TIMEOUT = 10_000; + +export const createOdpManager = (options: OdpManagerOptions): OdpManager => { + const segmentRequestHandler = new BrowserRequestHandler({ + timeout: options.segmentsApiTimeout || BROWSER_DEFAULT_API_TIMEOUT, + }); + + const eventRequestHandler = new BrowserRequestHandler({ + timeout: options.eventApiTimeout || BROWSER_DEFAULT_API_TIMEOUT, + }); + + return getOdpManager({ + ...options, + eventBatchSize: 1, + segmentRequestHandler, + eventRequestHandler, + eventRequestGenerator: pixelApiRequestGenerator, + }); +}; diff --git a/lib/odp/odp_manager_factory.node.spec.ts b/lib/odp/odp_manager_factory.node.spec.ts new file mode 100644 index 000000000..b63850180 --- /dev/null +++ b/lib/odp/odp_manager_factory.node.spec.ts @@ -0,0 +1,125 @@ +/** + * Copyright 2024, 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. + */ + +vi.mock('../utils/http_request_handler/node_request_handler', () => { + return { NodeRequestHandler: vi.fn() }; +}); + +vi.mock('./odp_manager_factory', () => { + return { getOdpManager: vi.fn().mockImplementation(() => ({})) }; +}); + + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { getOdpManager, OdpManagerOptions } from './odp_manager_factory'; +import { NODE_DEFAULT_API_TIMEOUT, NODE_DEFAULT_BATCH_SIZE, NODE_DEFAULT_FLUSH_INTERVAL, createOdpManager } from './odp_manager_factory.node'; +import { NodeRequestHandler } from '../utils/http_request_handler/node_request_handler'; +import { eventApiRequestGenerator } from './event_manager/odp_event_api_manager'; + +describe('createOdpManager', () => { + const MockNodeRequestHandler = vi.mocked(NodeRequestHandler); + const mockGetOdpManager = vi.mocked(getOdpManager); + + beforeEach(() => { + MockNodeRequestHandler.mockClear(); + mockGetOdpManager.mockClear(); + }); + + it('should use NodeRequestHandler with the provided timeout as the segment request handler', () => { + const odpManager = createOdpManager({ segmentsApiTimeout: 3456 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { segmentRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(segmentRequestHandler).toBe(MockNodeRequestHandler.mock.instances[0]); + const requestHandlerOptions = MockNodeRequestHandler.mock.calls[0][0]; + expect(requestHandlerOptions?.timeout).toBe(3456); + }); + + it('should use NodeRequestHandler with the node default timeout as the segment request handler', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { segmentRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(segmentRequestHandler).toBe(MockNodeRequestHandler.mock.instances[0]); + const requestHandlerOptions = MockNodeRequestHandler.mock.calls[0][0]; + expect(requestHandlerOptions?.timeout).toBe(NODE_DEFAULT_API_TIMEOUT); + }); + + it('should use NodeRequestHandler with the provided timeout as the event request handler', () => { + const odpManager = createOdpManager({ eventApiTimeout: 2345 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestHandler).toBe(MockNodeRequestHandler.mock.instances[1]); + const requestHandlerOptions = MockNodeRequestHandler.mock.calls[1][0]; + expect(requestHandlerOptions?.timeout).toBe(2345); + }); + + it('should use NodeRequestHandler with the node default timeout as the event request handler', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestHandler).toBe(MockNodeRequestHandler.mock.instances[1]); + const requestHandlerOptions = MockNodeRequestHandler.mock.calls[1][0]; + expect(requestHandlerOptions?.timeout).toBe(NODE_DEFAULT_API_TIMEOUT); + }); + + it('uses the event api request generator', () => { + const odpManager = createOdpManager({ }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestGenerator } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestGenerator).toBe(eventApiRequestGenerator); + }); + + it('should use the provided eventBatchSize', () => { + const odpManager = createOdpManager({ eventBatchSize: 99 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventBatchSize } = mockGetOdpManager.mock.calls[0][0]; + expect(eventBatchSize).toBe(99); + }); + + it('should use the node default eventBatchSize if none provided', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventBatchSize } = mockGetOdpManager.mock.calls[0][0]; + expect(eventBatchSize).toBe(NODE_DEFAULT_BATCH_SIZE); + }); + + it('should use the provided eventFlushInterval', () => { + const odpManager = createOdpManager({ eventFlushInterval: 9999 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventFlushInterval } = mockGetOdpManager.mock.calls[0][0]; + expect(eventFlushInterval).toBe(9999); + }); + + it('should use the node default eventFlushInterval if none provided', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventFlushInterval } = mockGetOdpManager.mock.calls[0][0]; + expect(eventFlushInterval).toBe(NODE_DEFAULT_FLUSH_INTERVAL); + }); + + it('uses the passed options for relevant fields', () => { + const options: OdpManagerOptions = { + segmentsCache: {} as any, + segmentsCacheSize: 11, + segmentsCacheTimeout: 2025, + segmentManager: {} as any, + eventManager: {} as any, + userAgentParser: {} as any, + }; + const odpManager = createOdpManager(options); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + expect(mockGetOdpManager).toHaveBeenNthCalledWith(1, expect.objectContaining(options)); + }); +}); diff --git a/lib/odp/odp_manager_factory.node.ts b/lib/odp/odp_manager_factory.node.ts new file mode 100644 index 000000000..3d449fd3b --- /dev/null +++ b/lib/odp/odp_manager_factory.node.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2024, 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 { NodeRequestHandler } from '../utils/http_request_handler/node_request_handler'; +import { eventApiRequestGenerator } from './event_manager/odp_event_api_manager'; +import { OdpManager } from './odp_manager'; +import { getOdpManager, OdpManagerOptions } from './odp_manager_factory'; + +export const NODE_DEFAULT_API_TIMEOUT = 10_000; +export const NODE_DEFAULT_BATCH_SIZE = 10; +export const NODE_DEFAULT_FLUSH_INTERVAL = 1000; + +export const createOdpManager = (options: OdpManagerOptions): OdpManager => { + const segmentRequestHandler = new NodeRequestHandler({ + timeout: options.segmentsApiTimeout || NODE_DEFAULT_API_TIMEOUT, + }); + + const eventRequestHandler = new NodeRequestHandler({ + timeout: options.eventApiTimeout || NODE_DEFAULT_API_TIMEOUT, + }); + + return getOdpManager({ + ...options, + segmentRequestHandler, + eventRequestHandler, + eventBatchSize: options.eventBatchSize || NODE_DEFAULT_BATCH_SIZE, + eventFlushInterval: options.eventFlushInterval || NODE_DEFAULT_FLUSH_INTERVAL, + eventRequestGenerator: eventApiRequestGenerator, + }); +}; diff --git a/lib/odp/odp_manager_factory.react_native.spec.ts b/lib/odp/odp_manager_factory.react_native.spec.ts new file mode 100644 index 000000000..604a71bc7 --- /dev/null +++ b/lib/odp/odp_manager_factory.react_native.spec.ts @@ -0,0 +1,125 @@ +/** + * Copyright 2024, 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. + */ + +vi.mock('../utils/http_request_handler/browser_request_handler', () => { + return { BrowserRequestHandler: vi.fn() }; +}); + +vi.mock('./odp_manager_factory', () => { + return { getOdpManager: vi.fn().mockImplementation(() => ({})) }; +}); + + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { getOdpManager, OdpManagerOptions } from './odp_manager_factory'; +import { RN_DEFAULT_API_TIMEOUT, RN_DEFAULT_BATCH_SIZE, RN_DEFAULT_FLUSH_INTERVAL, createOdpManager } from './odp_manager_factory.react_native'; +import { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler' +import { eventApiRequestGenerator } from './event_manager/odp_event_api_manager'; + +describe('createOdpManager', () => { + const MockBrowserRequestHandler = vi.mocked(BrowserRequestHandler); + const mockGetOdpManager = vi.mocked(getOdpManager); + + beforeEach(() => { + MockBrowserRequestHandler.mockClear(); + mockGetOdpManager.mockClear(); + }); + + it('should use BrowserRequestHandler with the provided timeout as the segment request handler', () => { + const odpManager = createOdpManager({ segmentsApiTimeout: 3456 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { segmentRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(segmentRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[0]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[0][0]; + expect(requestHandlerOptions?.timeout).toBe(3456); + }); + + it('should use BrowserRequestHandler with the node default timeout as the segment request handler', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { segmentRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(segmentRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[0]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[0][0]; + expect(requestHandlerOptions?.timeout).toBe(RN_DEFAULT_API_TIMEOUT); + }); + + it('should use BrowserRequestHandler with the provided timeout as the event request handler', () => { + const odpManager = createOdpManager({ eventApiTimeout: 2345 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[1]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[1][0]; + expect(requestHandlerOptions?.timeout).toBe(2345); + }); + + it('should use BrowserRequestHandler with the node default timeout as the event request handler', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestHandler } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestHandler).toBe(MockBrowserRequestHandler.mock.instances[1]); + const requestHandlerOptions = MockBrowserRequestHandler.mock.calls[1][0]; + expect(requestHandlerOptions?.timeout).toBe(RN_DEFAULT_API_TIMEOUT); + }); + + it('uses the event api request generator', () => { + const odpManager = createOdpManager({ }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventRequestGenerator } = mockGetOdpManager.mock.calls[0][0]; + expect(eventRequestGenerator).toBe(eventApiRequestGenerator); + }); + + it('should use the provided eventBatchSize', () => { + const odpManager = createOdpManager({ eventBatchSize: 99 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventBatchSize } = mockGetOdpManager.mock.calls[0][0]; + expect(eventBatchSize).toBe(99); + }); + + it('should use the react_native default eventBatchSize if none provided', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventBatchSize } = mockGetOdpManager.mock.calls[0][0]; + expect(eventBatchSize).toBe(RN_DEFAULT_BATCH_SIZE); + }); + + it('should use the provided eventFlushInterval', () => { + const odpManager = createOdpManager({ eventFlushInterval: 9999 }); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventFlushInterval } = mockGetOdpManager.mock.calls[0][0]; + expect(eventFlushInterval).toBe(9999); + }); + + it('should use the react_native default eventFlushInterval if none provided', () => { + const odpManager = createOdpManager({}); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + const { eventFlushInterval } = mockGetOdpManager.mock.calls[0][0]; + expect(eventFlushInterval).toBe(RN_DEFAULT_FLUSH_INTERVAL); + }); + + it('uses the passed options for relevant fields', () => { + const options: OdpManagerOptions = { + segmentsCache: {} as any, + segmentsCacheSize: 11, + segmentsCacheTimeout: 2025, + segmentManager: {} as any, + eventManager: {} as any, + userAgentParser: {} as any, + }; + const odpManager = createOdpManager(options); + expect(odpManager).toBe(mockGetOdpManager.mock.results[0].value); + expect(mockGetOdpManager).toHaveBeenNthCalledWith(1, expect.objectContaining(options)); + }); +}); diff --git a/lib/odp/odp_manager_factory.react_native.ts b/lib/odp/odp_manager_factory.react_native.ts new file mode 100644 index 000000000..c63982430 --- /dev/null +++ b/lib/odp/odp_manager_factory.react_native.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2024, 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 { BrowserRequestHandler } from '../utils/http_request_handler/browser_request_handler'; +import { eventApiRequestGenerator } from './event_manager/odp_event_api_manager'; +import { OdpManager } from './odp_manager'; +import { getOdpManager, OdpManagerOptions } from './odp_manager_factory'; + +export const RN_DEFAULT_API_TIMEOUT = 10_000; +export const RN_DEFAULT_BATCH_SIZE = 10; +export const RN_DEFAULT_FLUSH_INTERVAL = 1000; + +export const createOdpManager = (options: OdpManagerOptions): OdpManager => { + const segmentRequestHandler = new BrowserRequestHandler({ + timeout: options.segmentsApiTimeout || RN_DEFAULT_API_TIMEOUT, + }); + + const eventRequestHandler = new BrowserRequestHandler({ + timeout: options.eventApiTimeout || RN_DEFAULT_API_TIMEOUT, + }); + + return getOdpManager({ + ...options, + segmentRequestHandler, + eventRequestHandler, + eventBatchSize: options.eventBatchSize || RN_DEFAULT_BATCH_SIZE, + eventFlushInterval: options.eventFlushInterval || RN_DEFAULT_FLUSH_INTERVAL, + eventRequestGenerator: eventApiRequestGenerator, + }); +}; diff --git a/lib/odp/odp_manager_factory.spec.ts b/lib/odp/odp_manager_factory.spec.ts new file mode 100644 index 000000000..94aa565e5 --- /dev/null +++ b/lib/odp/odp_manager_factory.spec.ts @@ -0,0 +1,405 @@ +/** + * Copyright 2024, 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. + */ + +vi.mock('./odp_manager', () => { + return { + DefaultOdpManager: vi.fn(), + }; +}); + +vi.mock('./segment_manager/odp_segment_manager', () => { + return { + DefaultOdpSegmentManager: vi.fn(), + }; +}); + +vi.mock('./segment_manager/odp_segment_api_manager', () => { + return { + DefaultOdpSegmentApiManager: vi.fn(), + }; +}); + +vi.mock('../utils/cache/in_memory_lru_cache', () => { + return { + InMemoryLruCache: vi.fn(), + }; +}); + +vi.mock('./event_manager/odp_event_manager', () => { + return { + DefaultOdpEventManager: vi.fn(), + }; +}); + +vi.mock('./event_manager/odp_event_api_manager', () => { + return { + DefaultOdpEventApiManager: vi.fn(), + }; +}); + +vi.mock( '../utils/repeater/repeater', () => { + return { + IntervalRepeater: vi.fn(), + ExponentialBackoff: vi.fn(), + }; +}); + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { DefaultOdpManager } from './odp_manager'; +import { DEFAULT_CACHE_SIZE, DEFAULT_CACHE_TIMEOUT, DEFAULT_EVENT_BATCH_SIZE, DEFAULT_EVENT_MAX_BACKOFF, DEFAULT_EVENT_MAX_RETRIES, DEFAULT_EVENT_MIN_BACKOFF, getOdpManager } from './odp_manager_factory'; +import { getMockRequestHandler } from '../tests/mock/mock_request_handler'; +import { DefaultOdpSegmentManager } from './segment_manager/odp_segment_manager'; +import { DefaultOdpSegmentApiManager } from './segment_manager/odp_segment_api_manager'; +import { InMemoryLruCache } from '../utils/cache/in_memory_lru_cache'; +import { DefaultOdpEventManager } from './event_manager/odp_event_manager'; +import { DefaultOdpEventApiManager } from './event_manager/odp_event_api_manager'; +import { IntervalRepeater } from '../utils/repeater/repeater'; +import { ExponentialBackoff } from '../utils/repeater/repeater'; + +describe('getOdpManager', () => { + const MockDefaultOdpManager = vi.mocked(DefaultOdpManager); + const MockDefaultOdpSegmentManager = vi.mocked(DefaultOdpSegmentManager); + const MockDefaultOdpSegmentApiManager = vi.mocked(DefaultOdpSegmentApiManager); + const MockInMemoryLruCache = vi.mocked(InMemoryLruCache); + const MockDefaultOdpEventManager = vi.mocked(DefaultOdpEventManager); + const MockDefaultOdpEventApiManager = vi.mocked(DefaultOdpEventApiManager); + const MockIntervalRepeater = vi.mocked(IntervalRepeater); + const MockExponentialBackoff = vi.mocked(ExponentialBackoff); + + beforeEach(() => { + MockDefaultOdpManager.mockClear(); + MockDefaultOdpSegmentManager.mockClear(); + MockDefaultOdpSegmentApiManager.mockClear(); + MockInMemoryLruCache.mockClear(); + MockDefaultOdpEventManager.mockClear(); + MockDefaultOdpEventApiManager.mockClear(); + MockIntervalRepeater.mockClear(); + MockExponentialBackoff.mockClear(); + }); + + it('should use provided segment manager', () => { + const segmentManager = {} as any; + + const odpManager = getOdpManager({ + segmentManager, + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(Object.is(odpManager, MockDefaultOdpManager.mock.instances[0])).toBe(true); + const { segmentManager: usedSegmentManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedSegmentManager).toBe(segmentManager); + }); + + describe('when no segment manager is provided', () => { + it('should create a default segment manager with default api manager using the passed eventRequestHandler', () => { + const segmentRequestHandler = getMockRequestHandler(); + const odpManager = getOdpManager({ + segmentRequestHandler, + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(Object.is(odpManager, MockDefaultOdpManager.mock.instances[0])).toBe(true); + const { segmentManager: usedSegmentManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(Object.is(usedSegmentManager, MockDefaultOdpSegmentManager.mock.instances[0])).toBe(true); + const apiManager = MockDefaultOdpSegmentManager.mock.calls[0][1]; + expect(Object.is(apiManager, MockDefaultOdpSegmentApiManager.mock.instances[0])).toBe(true); + const usedRequestHandler = MockDefaultOdpSegmentApiManager.mock.calls[0][0]; + expect(Object.is(usedRequestHandler, segmentRequestHandler)).toBe(true); + }); + + it('should create a default segment manager with the provided segment cache', () => { + const segmentsCache = {} as any; + + const odpManager = getOdpManager({ + segmentsCache, + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(Object.is(odpManager, MockDefaultOdpManager.mock.instances[0])).toBe(true); + const { segmentManager: usedSegmentManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(Object.is(usedSegmentManager, MockDefaultOdpSegmentManager.mock.instances[0])).toBe(true); + const usedCache = MockDefaultOdpSegmentManager.mock.calls[0][0]; + expect(usedCache).toBe(segmentsCache); + }); + + describe('when no segment cache is provided', () => { + it('should use a InMemoryLruCache with the provided size', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + segmentsCacheSize: 3141, + }); + + expect(Object.is(odpManager, MockDefaultOdpManager.mock.instances[0])).toBe(true); + const { segmentManager: usedSegmentManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(Object.is(usedSegmentManager, MockDefaultOdpSegmentManager.mock.instances[0])).toBe(true); + const usedCache = MockDefaultOdpSegmentManager.mock.calls[0][0]; + expect(usedCache).toBe(MockInMemoryLruCache.mock.instances[0]); + expect(MockInMemoryLruCache.mock.calls[0][0]).toBe(3141); + }); + + it('should use a InMemoryLruCache with default size if no segmentCacheSize is provided', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(Object.is(odpManager, MockDefaultOdpManager.mock.instances[0])).toBe(true); + const { segmentManager: usedSegmentManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(Object.is(usedSegmentManager, MockDefaultOdpSegmentManager.mock.instances[0])).toBe(true); + const usedCache = MockDefaultOdpSegmentManager.mock.calls[0][0]; + expect(usedCache).toBe(MockInMemoryLruCache.mock.instances[0]); + expect(MockInMemoryLruCache.mock.calls[0][0]).toBe(DEFAULT_CACHE_SIZE); + }); + + it('should use a InMemoryLruCache with the provided timeout', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + segmentsCacheTimeout: 123456, + }); + + expect(Object.is(odpManager, MockDefaultOdpManager.mock.instances[0])).toBe(true); + const { segmentManager: usedSegmentManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(Object.is(usedSegmentManager, MockDefaultOdpSegmentManager.mock.instances[0])).toBe(true); + const usedCache = MockDefaultOdpSegmentManager.mock.calls[0][0]; + expect(usedCache).toBe(MockInMemoryLruCache.mock.instances[0]); + expect(MockInMemoryLruCache.mock.calls[0][1]).toBe(123456); + }); + + it('should use a InMemoryLruCache with default timeout if no segmentsCacheTimeout is provided', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(Object.is(odpManager, MockDefaultOdpManager.mock.instances[0])).toBe(true); + const { segmentManager: usedSegmentManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(Object.is(usedSegmentManager, MockDefaultOdpSegmentManager.mock.instances[0])).toBe(true); + const usedCache = MockDefaultOdpSegmentManager.mock.calls[0][0]; + expect(usedCache).toBe(MockInMemoryLruCache.mock.instances[0]); + expect(MockInMemoryLruCache.mock.calls[0][1]).toBe(DEFAULT_CACHE_TIMEOUT); + }); + }); + }); + + it('uses provided event manager', () => { + const eventManager = {} as any; + + const odpManager = getOdpManager({ + eventManager, + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(eventManager); + }); + + describe('when no event manager is provided', () => { + it('should use a default event manager with default api manager using the passed eventRequestHandler and eventRequestGenerator', () => { + const eventRequestHandler = getMockRequestHandler(); + const eventRequestGenerator = vi.fn(); + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler, + eventRequestGenerator, + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const apiManager = MockDefaultOdpEventManager.mock.calls[0][0].apiManager; + expect(apiManager).toBe(MockDefaultOdpEventApiManager.mock.instances[0]); + const usedRequestHandler = MockDefaultOdpEventApiManager.mock.calls[0][0]; + expect(usedRequestHandler).toBe(eventRequestHandler); + const usedRequestGenerator = MockDefaultOdpEventApiManager.mock.calls[0][1]; + expect(usedRequestGenerator).toBe(eventRequestGenerator); + }); + + it('should use a default event manager with the provided event batch size', () => { + const eventBatchSize = 1234; + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + eventBatchSize, + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedBatchSize = MockDefaultOdpEventManager.mock.calls[0][0].batchSize; + expect(usedBatchSize).toBe(eventBatchSize); + }); + + it('should use a default event manager with the default batch size if no eventBatchSize is provided', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedBatchSize = MockDefaultOdpEventManager.mock.calls[0][0].batchSize; + expect(usedBatchSize).toBe(DEFAULT_EVENT_BATCH_SIZE); + }); + + it('should use a default event manager with an interval repeater with the provided flush interval', () => { + const eventFlushInterval = 1234; + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + eventFlushInterval, + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedRepeater = MockDefaultOdpEventManager.mock.calls[0][0].repeater; + expect(usedRepeater).toBe(MockIntervalRepeater.mock.instances[0]); + const usedInterval = MockIntervalRepeater.mock.calls[0][0]; + expect(usedInterval).toBe(eventFlushInterval); + }); + + it('should use a default event manager with the provided max retries', () => { + const eventMaxRetries = 7; + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + eventMaxRetries, + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedMaxRetries = MockDefaultOdpEventManager.mock.calls[0][0].retryConfig.maxRetries; + expect(usedMaxRetries).toBe(eventMaxRetries); + }); + + it('should use a default event manager with the default max retries if no eventMaxRetries is provided', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedMaxRetries = MockDefaultOdpEventManager.mock.calls[0][0].retryConfig.maxRetries; + expect(usedMaxRetries).toBe(DEFAULT_EVENT_MAX_RETRIES); + }); + + it('should use a default event manager with ExponentialBackoff with provided minBackoff', () => { + const eventMinBackoff = 1234; + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + eventMinBackoff, + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedBackoffProvider = MockDefaultOdpEventManager.mock.calls[0][0].retryConfig.backoffProvider; + const backoff = usedBackoffProvider(); + expect(Object.is(backoff, MockExponentialBackoff.mock.instances[0])).toBe(true); + expect(MockExponentialBackoff.mock.calls[0][0]).toBe(eventMinBackoff); + }); + + it('should use a default event manager with ExponentialBackoff with default min backoff if none provided', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedBackoffProvider = MockDefaultOdpEventManager.mock.calls[0][0].retryConfig.backoffProvider; + const backoff = usedBackoffProvider(); + expect(Object.is(backoff, MockExponentialBackoff.mock.instances[0])).toBe(true); + expect(MockExponentialBackoff.mock.calls[0][0]).toBe(DEFAULT_EVENT_MIN_BACKOFF); + }); + + it('should use a default event manager with ExponentialBackoff with provided maxBackoff', () => { + const eventMaxBackoff = 9999; + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + eventMaxBackoff: eventMaxBackoff, + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedBackoffProvider = MockDefaultOdpEventManager.mock.calls[0][0].retryConfig.backoffProvider; + const backoff = usedBackoffProvider(); + expect(Object.is(backoff, MockExponentialBackoff.mock.instances[0])).toBe(true); + expect(MockExponentialBackoff.mock.calls[0][1]).toBe(eventMaxBackoff); + }); + + it('should use a default event manager with ExponentialBackoff with default max backoff if none provided', () => { + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { eventManager: usedEventManager } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedEventManager).toBe(MockDefaultOdpEventManager.mock.instances[0]); + const usedBackoffProvider = MockDefaultOdpEventManager.mock.calls[0][0].retryConfig.backoffProvider; + const backoff = usedBackoffProvider(); + expect(Object.is(backoff, MockExponentialBackoff.mock.instances[0])).toBe(true); + expect(MockExponentialBackoff.mock.calls[0][1]).toBe(DEFAULT_EVENT_MAX_BACKOFF); + }); + }); + + it('should use the provided userAgentParser', () => { + const userAgentParser = {} as any; + + const odpManager = getOdpManager({ + segmentRequestHandler: getMockRequestHandler(), + eventRequestHandler: getMockRequestHandler(), + eventRequestGenerator: vi.fn(), + userAgentParser, + }); + + expect(odpManager).toBe(MockDefaultOdpManager.mock.instances[0]); + const { userAgentParser: usedUserAgentParser } = MockDefaultOdpManager.mock.calls[0][0]; + expect(usedUserAgentParser).toBe(userAgentParser); + }); +}); diff --git a/lib/odp/odp_manager_factory.ts b/lib/odp/odp_manager_factory.ts new file mode 100644 index 000000000..31d908df1 --- /dev/null +++ b/lib/odp/odp_manager_factory.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2024, 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 { RequestHandler } from "../shared_types"; +import { Cache } from "../utils/cache/cache"; +import { InMemoryLruCache } from "../utils/cache/in_memory_lru_cache"; +import { ExponentialBackoff, IntervalRepeater } from "../utils/repeater/repeater"; +import { DefaultOdpEventApiManager, EventRequestGenerator } from "./event_manager/odp_event_api_manager"; +import { DefaultOdpEventManager, OdpEventManager } from "./event_manager/odp_event_manager"; +import { DefaultOdpManager, OdpManager } from "./odp_manager"; +import { DefaultOdpSegmentApiManager } from "./segment_manager/odp_segment_api_manager"; +import { DefaultOdpSegmentManager, OdpSegmentManager } from "./segment_manager/odp_segment_manager"; +import { UserAgentParser } from "./ua_parser/user_agent_parser"; + +export const DEFAULT_CACHE_SIZE = 1000; +export const DEFAULT_CACHE_TIMEOUT = 600_000; + +export const DEFAULT_EVENT_BATCH_SIZE = 100; +export const DEFAULT_EVENT_FLUSH_INTERVAL = 10_000; +export const DEFAULT_EVENT_MAX_RETRIES = 5; +export const DEFAULT_EVENT_MIN_BACKOFF = 1000; +export const DEFAULT_EVENT_MAX_BACKOFF = 32_000; + +export type OdpManagerOptions = { + segmentsCache?: Cache; + segmentsCacheSize?: number; + segmentsCacheTimeout?: number; + segmentsApiTimeout?: number; + segmentManager?: OdpSegmentManager; + eventFlushInterval?: number; + eventBatchSize?: number; + eventApiTimeout?: number; + eventManager?: OdpEventManager; + userAgentParser?: UserAgentParser; +}; + +export type OdpManagerFactoryOptions = Omit & { + segmentRequestHandler: RequestHandler; + eventRequestHandler: RequestHandler; + eventRequestGenerator: EventRequestGenerator; + eventMaxRetries?: number; + eventMinBackoff?: number; + eventMaxBackoff?: number; +} + +const getDefaultSegmentsCache = (cacheSize?: number, cacheTimeout?: number) => { + return new InMemoryLruCache(cacheSize || DEFAULT_CACHE_SIZE, cacheTimeout || DEFAULT_CACHE_TIMEOUT); +} + +const getDefaultSegmentManager = (options: OdpManagerFactoryOptions) => { + return new DefaultOdpSegmentManager( + options.segmentsCache || getDefaultSegmentsCache(options.segmentsCacheSize, options.segmentsCacheTimeout), + new DefaultOdpSegmentApiManager(options.segmentRequestHandler), + ); +}; + +const getDefaultEventManager = (options: OdpManagerFactoryOptions) => { + return new DefaultOdpEventManager({ + apiManager: new DefaultOdpEventApiManager(options.eventRequestHandler, options.eventRequestGenerator), + batchSize: options.eventBatchSize || DEFAULT_EVENT_BATCH_SIZE, + repeater: new IntervalRepeater(options.eventFlushInterval || DEFAULT_EVENT_FLUSH_INTERVAL), + retryConfig: { + maxRetries: options.eventMaxRetries || DEFAULT_EVENT_MAX_RETRIES, + backoffProvider: () => new ExponentialBackoff( + options.eventMinBackoff || DEFAULT_EVENT_MIN_BACKOFF, + options.eventMaxBackoff || DEFAULT_EVENT_MAX_BACKOFF, + 500, + ), + }, + }); +} + +export const getOdpManager = (options: OdpManagerFactoryOptions): OdpManager => { + const segmentManager = options.segmentManager || getDefaultSegmentManager(options); + const eventManager = options.eventManager || getDefaultEventManager(options); + + return new DefaultOdpManager({ + segmentManager, + eventManager, + userAgentParser: options.userAgentParser, + }); +}; diff --git a/lib/odp/odp_types.ts b/lib/odp/odp_types.ts index bd3e8217e..abe47b245 100644 --- a/lib/odp/odp_types.ts +++ b/lib/odp/odp_types.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/odp/odp_response_schema.ts b/lib/odp/segment_manager/odp_response_schema.ts similarity index 99% rename from lib/odp/odp_response_schema.ts rename to lib/odp/segment_manager/odp_response_schema.ts index 9aad4ac35..4221178af 100644 --- a/lib/odp/odp_response_schema.ts +++ b/lib/odp/segment_manager/odp_response_schema.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022, 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. diff --git a/lib/odp/segment_manager/odp_segment_api_manager.spec.ts b/lib/odp/segment_manager/odp_segment_api_manager.spec.ts new file mode 100644 index 000000000..52237add9 --- /dev/null +++ b/lib/odp/segment_manager/odp_segment_api_manager.spec.ts @@ -0,0 +1,245 @@ +/** + * Copyright 2022-2024 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 { describe, it, expect } from 'vitest'; + +import { ODP_USER_KEY } from '../constant'; +import { getMockRequestHandler } from '../../tests/mock/mock_request_handler'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import { DefaultOdpSegmentApiManager } from './odp_segment_api_manager'; + +const API_KEY = 'not-real-api-key'; +const GRAPHQL_ENDPOINT = 'https://some.example.com/graphql/endpoint'; +const USER_KEY = ODP_USER_KEY.FS_USER_ID; +const USER_VALUE = 'tester-101'; +const SEGMENTS_TO_CHECK = ['has_email', 'has_email_opted_in', 'push_on_sale']; + +describe('DefaultOdpSegmentApiManager', () => { + it('should return empty list without calling api when segmentsToCheck is empty', async () => { + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: '' }), + }); + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, []); + + expect(segments).toEqual([]); + expect(requestHandler.makeRequest).not.toHaveBeenCalled(); + }); + + it('should return null and log error if requestHandler promise rejects', async () => { + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.reject(new Error('Request timed out')), + }); + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + expect(logger.error).toHaveBeenCalledOnce(); + }); + + it('should log error and return null in case of non 200 HTTP status code response', async () => { + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 500, body: '' }), + }); + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + expect(logger.error).toHaveBeenCalledOnce(); + }); + + it('should return null and log error if response body is invalid JSON', async () => { + const invalidJsonResponse = 'not-a-valid-json-response'; + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: invalidJsonResponse }), + }); + + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + expect(logger.error).toHaveBeenCalledOnce(); + }); + + it('should return null and log error if response body is unrecognized JSON', async () => { + const invalidJsonResponse = '{"a":1}'; + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: invalidJsonResponse }), + }); + + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + expect(logger.error).toHaveBeenCalledOnce(); + }); + + it('should log error and return null in case of invalid identifier error response', 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":{"code": "INVALID_IDENTIFIER_EXCEPTION","classification":"DataFetchingException"}}],' + + '"data":{"customer":null}}'; + + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: errorJsonResponse }), + }); + + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, 'mock_user_id', SEGMENTS_TO_CHECK); + expect(segments).toBeNull(); + expect(logger.error).toHaveBeenCalledWith('Audience segments fetch failed (invalid identifier)'); + }); + + it('should log error and return null in case of errors other than invalid identifier error', 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}}'; + + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: errorJsonResponse }), + }); + + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, 'mock_user_id', SEGMENTS_TO_CHECK); + expect(segments).toBeNull(); + expect(logger.error).toHaveBeenCalledWith('Audience segments fetch failed (DataFetchingException)'); + }); + + it('should log error and return null in case of response with invalid falsy edges field', async () => { + const jsonResponse = `{ + "data": { + "customer": { + "audiences": { + } + } + } + }`; + + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: jsonResponse }), + }); + + const logger = getMockLogger(); + const manager = new DefaultOdpSegmentApiManager(requestHandler, logger); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + expect(logger.error).toHaveBeenCalledOnce(); + }); + + it('should parse a success response and return qualified segments', async () => { + const validJsonResponse = `{ + "data": { + "customer": { + "audiences": { + "edges": [ + { + "node": { + "name": "has_email", + "state": "qualified" + } + }, + { + "node": { + "name": "has_email_opted_in", + "state": "not-qualified" + } + } + ] + } + } + } + }`; + + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: validJsonResponse }), + }); + + const manager = new DefaultOdpSegmentApiManager(requestHandler); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toEqual(['has_email']); + }); + + it('should handle empty qualified segments', async () => { + const responseJsonWithNoQualifiedSegments = '{"data":{"customer":{"audiences":' + '{"edges":[ ]}}}}'; + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: responseJsonWithNoQualifiedSegments }), + }); + + const manager = new DefaultOdpSegmentApiManager(requestHandler); + const segments = await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toEqual([]); + }); + + it('should construct a valid GraphQL query request', async () => { + const requestHandler = getMockRequestHandler(); + requestHandler.makeRequest.mockReturnValue({ + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200, body: '' }), + }); + + const manager = new DefaultOdpSegmentApiManager(requestHandler); + await manager.fetchSegments(API_KEY, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(requestHandler.makeRequest).toHaveBeenCalledWith( + `${GRAPHQL_ENDPOINT}/v3/graphql`, + { + 'Content-Type': 'application/json', + 'x-api-key': API_KEY, + }, + 'POST', + `{"query" : "query {customer(${USER_KEY} : \\"${USER_VALUE}\\") {audiences(subset: [\\"has_email\\",\\"has_email_opted_in\\",\\"push_on_sale\\"]) {edges {node {name state}}}}}"}` + ); + }); +}); diff --git a/lib/odp/segment_manager/odp_segment_api_manager.ts b/lib/odp/segment_manager/odp_segment_api_manager.ts index afe20ae2a..6b609a8a3 100644 --- a/lib/odp/segment_manager/odp_segment_api_manager.ts +++ b/lib/odp/segment_manager/odp_segment_api_manager.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,12 @@ * limitations under the License. */ -import { LogHandler, LogLevel } from '../../modules/logging'; +import { LoggerFacade, LogLevel } from '../../modules/logging'; import { validate } from '../../utils/json_schema_validator'; -import { OdpResponseSchema } from '../odp_response_schema'; -import { ODP_USER_KEY } from '../../utils/enums'; -import { RequestHandler, Response as HttpResponse } from '../../utils/http_request_handler/http'; +import { OdpResponseSchema } from './odp_response_schema'; +import { ODP_USER_KEY } from '../constant'; +import { RequestHandler } from '../../utils/http_request_handler/http'; import { Response as GraphQLResponse } from '../odp_types'; - /** * Expected value for a qualified/valid segment */ @@ -41,7 +40,7 @@ const AUDIENCE_FETCH_FAILURE_MESSAGE = 'Audience segments fetch failed'; /** * Manager for communicating with the Optimizely Data Platform GraphQL endpoint */ -export interface IOdpSegmentApiManager { +export interface OdpSegmentApiManager { fetchSegments( apiKey: string, apiHost: string, @@ -51,19 +50,11 @@ export interface IOdpSegmentApiManager { ): Promise; } -/** - * Concrete implementation for communicating with the ODP GraphQL endpoint - */ -export class OdpSegmentApiManager implements IOdpSegmentApiManager { - private readonly logger: LogHandler; +export class DefaultOdpSegmentApiManager implements OdpSegmentApiManager { + private readonly logger?: LoggerFacade; private readonly requestHandler: RequestHandler; - /** - * Communicates with Optimizely Data Platform's GraphQL endpoint - * @param requestHandler Desired request handler for testing - * @param logger Collect and record events/errors for this GraphQL implementation - */ - constructor(requestHandler: RequestHandler, logger: LogHandler) { + constructor(requestHandler: RequestHandler, logger?: LoggerFacade) { this.requestHandler = requestHandler; this.logger = logger; } @@ -83,11 +74,6 @@ export class OdpSegmentApiManager implements IOdpSegmentApiManager { userValue: string, segmentsToCheck: string[] ): Promise { - if (!apiKey || !apiHost) { - this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (Parameters apiKey or apiHost invalid)`); - return null; - } - if (segmentsToCheck?.length === 0) { return EMPTY_SEGMENTS_COLLECTION; } @@ -95,15 +81,15 @@ export class OdpSegmentApiManager implements IOdpSegmentApiManager { const endpoint = `${apiHost}/v3/graphql`; const query = this.toGraphQLJson(userKey, userValue, segmentsToCheck); - const segmentsResponse = await this.querySegments(apiKey, endpoint, userKey, userValue, query); + const segmentsResponse = await this.querySegments(apiKey, endpoint, query); if (!segmentsResponse) { - this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (network error)`); + this.logger?.error(`${AUDIENCE_FETCH_FAILURE_MESSAGE} (network error)`); return null; } const parsedSegments = this.parseSegmentsResponseJson(segmentsResponse); if (!parsedSegments) { - this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (decode error)`); + this.logger?.error(`${AUDIENCE_FETCH_FAILURE_MESSAGE} (decode error)`); return null; } @@ -111,9 +97,9 @@ export class OdpSegmentApiManager implements IOdpSegmentApiManager { const { code, classification } = parsedSegments.errors[0].extensions; if (code == 'INVALID_IDENTIFIER_EXCEPTION') { - this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (invalid identifier)`); + this.logger?.error(`${AUDIENCE_FETCH_FAILURE_MESSAGE} (invalid identifier)`); } else { - this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (${classification})`); + this.logger?.error(`${AUDIENCE_FETCH_FAILURE_MESSAGE} (${classification})`); } return null; @@ -121,7 +107,7 @@ export class OdpSegmentApiManager implements IOdpSegmentApiManager { const edges = parsedSegments?.data?.customer?.audiences?.edges; if (!edges) { - this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (decode error)`); + this.logger?.error(`${AUDIENCE_FETCH_FAILURE_MESSAGE} (decode error)`); return null; } @@ -156,8 +142,6 @@ export class OdpSegmentApiManager implements IOdpSegmentApiManager { private async querySegments( apiKey: string, endpoint: string, - userKey: string, - userValue: string, query: string ): Promise { const method = 'POST'; @@ -167,15 +151,16 @@ export class OdpSegmentApiManager implements IOdpSegmentApiManager { 'x-api-key': apiKey, }; - let response: HttpResponse; try { const request = this.requestHandler.makeRequest(url, headers, method, query); - response = await request.responsePromise; + const { statusCode, body} = await request.responsePromise; + if (!(statusCode >= 200 && statusCode < 300)) { + return null; + } + return body; } catch { return null; } - - return response.body; } /** diff --git a/lib/odp/segment_manager/odp_segment_manager.spec.ts b/lib/odp/segment_manager/odp_segment_manager.spec.ts new file mode 100644 index 000000000..31598dd71 --- /dev/null +++ b/lib/odp/segment_manager/odp_segment_manager.spec.ts @@ -0,0 +1,180 @@ +/** + * Copyright 2022-2024, 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 { describe, it, expect, vi } from 'vitest'; + + +import { ODP_USER_KEY } from '../constant'; +import { DefaultOdpSegmentManager } from './odp_segment_manager'; +import { OdpConfig } from '../odp_config'; +import { OptimizelySegmentOption } from './optimizely_segment_option'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import { getMockSyncCache } from '../../tests/mock/mock_cache'; + +const API_KEY = 'test-api-key'; +const API_HOST = 'https://odp.example.com'; +const PIXEL_URL = 'https://odp.pixel.com'; +const SEGMENTS_TO_CHECK = ['segment1', 'segment2']; + +const config = new OdpConfig(API_KEY, API_HOST, PIXEL_URL, SEGMENTS_TO_CHECK); + +const getMockApiManager = () => { + return { + fetchSegments: vi.fn(), + }; +}; + +const userKey: ODP_USER_KEY = ODP_USER_KEY.FS_USER_ID; +const userValue = 'test-user'; + +describe('DefaultOdpSegmentManager', () => { + it('should return null and log error if the ODP config is not available.', async () => { + const logger = getMockLogger(); + const cache = getMockSyncCache(); + const manager = new DefaultOdpSegmentManager(cache, getMockApiManager(), logger); + expect(await manager.fetchQualifiedSegments(userKey, userValue)).toBeNull(); + expect(logger.warn).toHaveBeenCalledOnce(); + }); + + it('should return null and log error if ODP is not integrated.', async () => { + const logger = getMockLogger(); + const cache = getMockSyncCache(); + const manager = new DefaultOdpSegmentManager(cache, getMockApiManager(), logger); + manager.updateConfig({ integrated: false }); + expect(await manager.fetchQualifiedSegments(userKey, userValue)).toBeNull(); + expect(logger.warn).toHaveBeenCalledOnce(); + }); + + it('should fetch segments from apiManager using correct config on cache miss and save to cache.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + apiManager.fetchSegments.mockResolvedValue(['k', 'l']); + const manager = new DefaultOdpSegmentManager(cache, apiManager); + manager.updateConfig({ integrated: true, odpConfig: config }); + + const segments = await manager.fetchQualifiedSegments(userKey, userValue); + expect(segments).toEqual(['k', 'l']); + expect(apiManager.fetchSegments).toHaveBeenCalledWith(API_KEY, API_HOST, userKey, userValue, SEGMENTS_TO_CHECK); + expect(cache.get(manager.makeCacheKey(userKey, userValue))).toEqual(['k', 'l']); + }); + + it('should return segment from cache and not call apiManager on cache hit.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + + const manager = new DefaultOdpSegmentManager(cache, apiManager); + manager.updateConfig({ integrated: true, odpConfig: config }); + + cache.set(manager.makeCacheKey(userKey, userValue), ['x']); + + const segments = await manager.fetchQualifiedSegments(userKey, userValue); + expect(segments).toEqual(['x']); + + expect(apiManager.fetchSegments).not.toHaveBeenCalled(); + }); + + it('should return null when apiManager returns null.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + apiManager.fetchSegments.mockResolvedValue(null); + const manager = new DefaultOdpSegmentManager(cache, apiManager); + manager.updateConfig({ integrated: true, odpConfig: config }); + + const segments = await manager.fetchQualifiedSegments(userKey, userValue); + expect(segments).toBeNull(); + }); + + it('should ignore the cache if the option enum is included in the options array.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + apiManager.fetchSegments.mockResolvedValue(['k', 'l']); + const manager = new DefaultOdpSegmentManager(cache, apiManager); + manager.updateConfig({ integrated: true, odpConfig: config }); + cache.set(manager.makeCacheKey(userKey, userValue), ['x']); + + const segments = await manager.fetchQualifiedSegments(userKey, userValue, [OptimizelySegmentOption.IGNORE_CACHE]); + expect(segments).toEqual(['k', 'l']); + expect(cache.get(manager.makeCacheKey(userKey, userValue))).toEqual(['x']); + expect(apiManager.fetchSegments).toHaveBeenCalledWith(API_KEY, API_HOST, userKey, userValue, SEGMENTS_TO_CHECK); + }); + + it('should ignore the cache if the option string is included in the options array.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + apiManager.fetchSegments.mockResolvedValue(['k', 'l']); + const manager = new DefaultOdpSegmentManager(cache, apiManager); + manager.updateConfig({ integrated: true, odpConfig: config }); + cache.set(manager.makeCacheKey(userKey, userValue), ['x']); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const segments = await manager.fetchQualifiedSegments(userKey, userValue, ['IGNORE_CACHE']); + expect(segments).toEqual(['k', 'l']); + expect(cache.get(manager.makeCacheKey(userKey, userValue))).toEqual(['x']); + expect(apiManager.fetchSegments).toHaveBeenCalledWith(API_KEY, API_HOST, userKey, userValue, SEGMENTS_TO_CHECK); + }); + + it('should reset the cache if the option enum is included in the options array.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + apiManager.fetchSegments.mockResolvedValue(['k', 'l']); + + const manager = new DefaultOdpSegmentManager(cache, apiManager); + manager.updateConfig({ integrated: true, odpConfig: config }); + cache.set(manager.makeCacheKey(userKey, userValue), ['x']); + cache.set(manager.makeCacheKey(userKey, '123'), ['a']); + cache.set(manager.makeCacheKey(userKey, '456'), ['b']); + + const segments = await manager.fetchQualifiedSegments(userKey, userValue, [OptimizelySegmentOption.RESET_CACHE]); + expect(segments).toEqual(['k', 'l']); + expect(cache.get(manager.makeCacheKey(userKey, userValue))).toEqual(['k', 'l']); + expect(cache.size()).toBe(1); + }); + + it('should reset the cache if the option string is included in the options array.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + apiManager.fetchSegments.mockResolvedValue(['k', 'l']); + + const manager = new DefaultOdpSegmentManager(cache, apiManager); + manager.updateConfig({ integrated: true, odpConfig: config }); + cache.set(manager.makeCacheKey(userKey, userValue), ['x']); + cache.set(manager.makeCacheKey(userKey, '123'), ['a']); + cache.set(manager.makeCacheKey(userKey, '456'), ['b']); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const segments = await manager.fetchQualifiedSegments(userKey, userValue, ['RESET_CACHE']); + expect(segments).toEqual(['k', 'l']); + expect(cache.get(manager.makeCacheKey(userKey, userValue))).toEqual(['k', 'l']); + expect(cache.size()).toBe(1); + }); + + it('should reset the cache on config update.', async () => { + const cache = getMockSyncCache(); + const apiManager = getMockApiManager(); + apiManager.fetchSegments.mockResolvedValue(['k', 'l']); + + const manager = new DefaultOdpSegmentManager(cache, apiManager); + cache.set(manager.makeCacheKey(userKey, userValue), ['x']); + cache.set(manager.makeCacheKey(userKey, '123'), ['a']); + cache.set(manager.makeCacheKey(userKey, '456'), ['b']); + + expect(cache.size()).toBe(3); + manager.updateConfig({ integrated: true, odpConfig: config }); + expect(cache.size()).toBe(0); + }); +}); diff --git a/lib/odp/segment_manager/odp_segment_manager.ts b/lib/odp/segment_manager/odp_segment_manager.ts index 4aaa47dc3..dbf83a12f 100644 --- a/lib/odp/segment_manager/odp_segment_manager.ts +++ b/lib/odp/segment_manager/odp_segment_manager.ts @@ -14,70 +14,37 @@ * limitations under the License. */ -import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; -import { ERROR_MESSAGES, ODP_USER_KEY } from '../../utils/enums'; -import { ICache } from '../../utils/lru_cache'; -import { IOdpSegmentApiManager } from './odp_segment_api_manager'; -import { OdpConfig } from '../odp_config'; +import { ERROR_MESSAGES } from '../../utils/enums'; +import { Cache } from '../../utils/cache/cache'; +import { OdpSegmentApiManager } from './odp_segment_api_manager'; +import { OdpIntegrationConfig } from '../odp_config'; import { OptimizelySegmentOption } from './optimizely_segment_option'; +import { ODP_USER_KEY } from '../constant'; +import { LoggerFacade } from '../../modules/logging'; -export interface IOdpSegmentManager { +export interface OdpSegmentManager { fetchQualifiedSegments( userKey: ODP_USER_KEY, userValue: string, - options: Array + options?: Array ): Promise; - reset(): void; - makeCacheKey(userKey: string, userValue: string): string; - updateSettings(config: OdpConfig): void; + updateConfig(config: OdpIntegrationConfig): void; } -/** - * Schedules connections to ODP for audience segmentation and caches the results. - */ -export class OdpSegmentManager implements IOdpSegmentManager { - /** - * ODP configuration settings in used - * @private - */ - private odpConfig?: OdpConfig; - - /** - * Holds cached audience segments - * @private - */ - private _segmentsCache: ICache; - - /** - * Getter for private segments cache - * @public - */ - get segmentsCache(): ICache { - return this._segmentsCache; - } - - /** - * GraphQL API Manager used to fetch segments - * @private - */ - private odpSegmentApiManager: IOdpSegmentApiManager; - - /** - * Handler for recording execution logs - * @private - */ - private readonly logger: LogHandler; +export class DefaultOdpSegmentManager implements OdpSegmentManager { + private odpIntegrationConfig?: OdpIntegrationConfig; + private segmentsCache: Cache; + private odpSegmentApiManager: OdpSegmentApiManager + private logger?: LoggerFacade; constructor( - segmentsCache: ICache, - odpSegmentApiManager: IOdpSegmentApiManager, - logger?: LogHandler, - odpConfig?: OdpConfig, + segmentsCache: Cache, + odpSegmentApiManager: OdpSegmentApiManager, + logger?: LoggerFacade, ) { - this.odpConfig = odpConfig; - this._segmentsCache = segmentsCache; + this.segmentsCache = segmentsCache; this.odpSegmentApiManager = odpSegmentApiManager; - this.logger = logger || getLogger('OdpSegmentManager'); + this.logger = logger; } /** @@ -91,77 +58,62 @@ export class OdpSegmentManager implements IOdpSegmentManager { async fetchQualifiedSegments( userKey: ODP_USER_KEY, userValue: string, - options: Array + options?: Array ): Promise { - if (!this.odpConfig) { - this.logger.log(LogLevel.WARNING, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); + if (!this.odpIntegrationConfig) { + this.logger?.warn(ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); return null; } - const segmentsToCheck = this.odpConfig.segmentsToCheck; + if (!this.odpIntegrationConfig.integrated) { + this.logger?.warn(ERROR_MESSAGES.ODP_NOT_INTEGRATED); + return null; + } + + const odpConfig = this.odpIntegrationConfig.odpConfig; + + const segmentsToCheck = odpConfig.segmentsToCheck; if (!segmentsToCheck || segmentsToCheck.length <= 0) { - this.logger.log(LogLevel.DEBUG, 'No segments are used in the project. Returning an empty list.'); return []; } const cacheKey = this.makeCacheKey(userKey, userValue); - const ignoreCache = options.includes(OptimizelySegmentOption.IGNORE_CACHE); - const resetCache = options.includes(OptimizelySegmentOption.RESET_CACHE); + const ignoreCache = options?.includes(OptimizelySegmentOption.IGNORE_CACHE); + const resetCache = options?.includes(OptimizelySegmentOption.RESET_CACHE); if (resetCache) { - this.reset(); + this.segmentsCache.clear(); } - if (!ignoreCache && !resetCache) { - const cachedSegments = this._segmentsCache.lookup(cacheKey); + if (!ignoreCache) { + const cachedSegments = await this.segmentsCache.get(cacheKey); if (cachedSegments) { - this.logger.log(LogLevel.DEBUG, 'ODP cache hit. Returning segments from cache "%s".', cacheKey); return cachedSegments; } - this.logger.log(LogLevel.DEBUG, `ODP cache miss.`); } - this.logger.log(LogLevel.DEBUG, `Making a call to ODP server.`); - const segments = await this.odpSegmentApiManager.fetchSegments( - this.odpConfig.apiKey, - this.odpConfig.apiHost, + odpConfig.apiKey, + odpConfig.apiHost, userKey, userValue, segmentsToCheck ); if (segments && !ignoreCache) { - this._segmentsCache.save({ key: cacheKey, value: segments }); + this.segmentsCache.set(cacheKey, segments); } return segments; } - /** - * Clears the segments cache - */ - reset(): void { - this._segmentsCache.reset(); - } - - /** - * Creates a key used to identify which user fetchQualifiedSegments should lookup and save to in the segments cache - * @param userKey User type based on ODP_USER_KEY, such as "vuid" or "fs_user_id" - * @param userValue Arbitrary string, such as "test-user" - * @returns Concatenates inputs and returns the string "{userKey}-$-{userValue}" - */ makeCacheKey(userKey: string, userValue: string): string { return `${userKey}-$-${userValue}`; } - /** - * Updates the ODP Config settings of ODP Segment Manager - * @param config New ODP Config that will overwrite the existing config - */ - updateSettings(config: OdpConfig): void { - this.odpConfig = config; - this.reset(); + updateConfig(config: OdpIntegrationConfig): void { + this.odpIntegrationConfig = config; + this.segmentsCache.clear(); } } diff --git a/lib/odp/segment_manager/optimizely_segment_option.ts b/lib/odp/segment_manager/optimizely_segment_option.ts index 112cd39cc..cf7c801ef 100644 --- a/lib/odp/segment_manager/optimizely_segment_option.ts +++ b/lib/odp/segment_manager/optimizely_segment_option.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022, 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/odp/ua_parser/ua_parser.browser.ts b/lib/odp/ua_parser/ua_parser.browser.ts index e6cc27dc8..522c538be 100644 --- a/lib/odp/ua_parser/ua_parser.browser.ts +++ b/lib/odp/ua_parser/ua_parser.browser.ts @@ -16,9 +16,9 @@ import { UAParser } from 'ua-parser-js'; import { UserAgentInfo } from './user_agent_info'; -import { IUserAgentParser } from './user_agent_parser'; +import { UserAgentParser } from './user_agent_parser'; -const userAgentParser: IUserAgentParser = { +const userAgentParser: UserAgentParser = { parseUserAgentInfo(): UserAgentInfo { const parser = new UAParser(); const agentInfo = parser.getResult(); @@ -27,7 +27,7 @@ const userAgentParser: IUserAgentParser = { } } -export function getUserAgentParser(): IUserAgentParser { +export function getUserAgentParser(): UserAgentParser { return userAgentParser; } diff --git a/lib/odp/ua_parser/user_agent_parser.ts b/lib/odp/ua_parser/user_agent_parser.ts index 227065fb7..9ca30c141 100644 --- a/lib/odp/ua_parser/user_agent_parser.ts +++ b/lib/odp/ua_parser/user_agent_parser.ts @@ -16,6 +16,6 @@ import { UserAgentInfo } from "./user_agent_info"; -export interface IUserAgentParser { +export interface UserAgentParser { parseUserAgentInfo(): UserAgentInfo, } diff --git a/lib/optimizely/index.spec.ts b/lib/optimizely/index.spec.ts index ee1525e2d..364c05658 100644 --- a/lib/optimizely/index.spec.ts +++ b/lib/optimizely/index.spec.ts @@ -25,8 +25,9 @@ import testData from '../tests/test_data'; import { getForwardingEventProcessor } from '../event_processor/forwarding_event_processor'; import { LoggerFacade } from '../modules/logging'; import { createProjectConfig } from '../project_config/project_config'; +import { getMockLogger } from '../tests/mock/mock_logger'; -describe('lib/optimizely', () => { +describe('Optimizely', () => { const errorHandler = { handleError: function() {} }; const eventDispatcher = { @@ -35,18 +36,9 @@ describe('lib/optimizely', () => { const eventProcessor = getForwardingEventProcessor(eventDispatcher); - const createdLogger: LoggerFacade = { - ...logger.createLogger({ - logLevel: LOG_LEVEL.INFO, - }), - info: () => {}, - debug: () => {}, - warn: () => {}, - error: () => {}, - log: () => {}, - }; + const logger = getMockLogger(); - const notificationCenter = createNotificationCenter({ logger: createdLogger, errorHandler }); + const notificationCenter = createNotificationCenter({ logger, errorHandler }); it('should pass ssr to the project config manager', () => { const projectConfigManager = getMockProjectConfigManager({ @@ -60,7 +52,7 @@ describe('lib/optimizely', () => { projectConfigManager, errorHandler, jsonSchemaValidator, - logger: createdLogger, + logger, notificationCenter, eventProcessor, isSsr: true, diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 7628a0a17..4c4898c91 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -19,7 +19,8 @@ import { sprintf, objectValues } from '../utils/fns'; import { DefaultNotificationCenter, NotificationCenter } from '../notification_center'; import { EventProcessor } from '../event_processor/event_processor'; -import { IOdpManager } from '../odp/odp_manager'; +import { OdpManager } from '../odp/odp_manager'; +import { VuidManager } from '../vuid/vuid_manager'; import { OdpEvent } from '../odp/event_manager/odp_event'; import { OptimizelySegmentOption } from '../odp/segment_manager/optimizely_segment_option'; @@ -41,7 +42,6 @@ import { newErrorDecision } from '../optimizely_decision'; import OptimizelyUserContext from '../optimizely_user_context'; import { ProjectConfigManager } from '../project_config/project_config_manager'; import { createDecisionService, DecisionService, DecisionObj } from '../core/decision_service'; -// import { getImpressionEvent, getConversionEvent } from '../event_processor/event_builder'; import { buildLogEvent } from '../event_processor/event_builder/log_event'; import { buildImpressionEvent, buildConversionEvent, ImpressionEvent } from '../event_processor/event_builder/user_event'; import fns from '../utils/fns'; @@ -63,9 +63,6 @@ import { // NOTIFICATION_TYPES, NODE_CLIENT_ENGINE, CLIENT_VERSION, - ODP_DEFAULT_EVENT_TYPE, - FS_USER_ID_ALIAS, - ODP_USER_KEY, } from '../utils/enums'; import { Fn } from '../utils/type'; import { resolvablePromise } from '../utils/promise/resolvablePromise'; @@ -94,13 +91,14 @@ export default class Optimizely implements Client { private clientEngine: string; private clientVersion: string; private errorHandler: ErrorHandler; - protected logger: LoggerFacade; + private logger: LoggerFacade; private projectConfigManager: ProjectConfigManager; private decisionService: DecisionService; private eventProcessor?: EventProcessor; private defaultDecideOptions: { [key: string]: boolean }; - protected odpManager?: IOdpManager; + private odpManager?: OdpManager; public notificationCenter: DefaultNotificationCenter; + private vuidManager?: VuidManager; constructor(config: OptimizelyOptions) { let clientEngine = config.clientEngine; @@ -115,6 +113,7 @@ export default class Optimizely implements Client { this.isOptimizelyConfigValid = config.isValidInstance; this.logger = config.logger; this.odpManager = config.odpManager; + this.vuidManager = config.vuidManager; let decideOptionsArray = config.defaultDecideOptions ?? []; if (!Array.isArray(decideOptionsArray)) { @@ -185,9 +184,17 @@ export default class Optimizely implements Client { this.readyPromise = Promise.all([ projectConfigManagerRunningPromise, eventProcessorRunningPromise, - config.odpManager ? config.odpManager.onReady() : Promise.resolve(), + config.odpManager ? config.odpManager.onRunning() : Promise.resolve(), + config.vuidManager ? config.vuidManager.initialize() : Promise.resolve(), ]); + this.readyPromise.then(() => { + const vuid = this.vuidManager?.getVuid(); + if (vuid) { + this.odpManager?.setVuid(vuid); + } + }); + this.readyTimeouts = {}; this.nextReadyTimeoutId = 0; } @@ -1230,13 +1237,10 @@ export default class Optimizely implements Client { */ close(): Promise<{ success: boolean; reason?: string }> { try { - if (this.odpManager) { - this.odpManager.stop(); - } - - this.notificationCenter.clearAllNotificationListeners(); - + this.projectConfigManager.stop(); this.eventProcessor?.stop(); + this.odpManager?.stop(); + this.notificationCenter.clearAllNotificationListeners(); const eventProcessorStoppedPromise = this.eventProcessor ? this.eventProcessor.onTerminated() : Promise.resolve(); @@ -1245,9 +1249,7 @@ export default class Optimizely implements Client { this.disposeOnUpdate(); this.disposeOnUpdate = undefined; } - if (this.projectConfigManager) { - this.projectConfigManager.stop(); - } + Object.keys(this.readyTimeouts).forEach((readyTimeoutId: string) => { const readyTimeoutRecord = this.readyTimeouts[readyTimeoutId]; clearTimeout(readyTimeoutRecord.readyTimeout); @@ -1358,7 +1360,7 @@ export default class Optimizely implements Client { * null if provided inputs are invalid */ createUserContext(userId?: string, attributes?: UserAttributes): OptimizelyUserContext | null { - const userIdentifier = userId ?? this.odpManager?.getVuid(); + const userIdentifier = userId ?? this.vuidManager?.getVuid(); if (userIdentifier === undefined || !this.validateInputs({ user_id: userIdentifier }, attributes)) { return null; @@ -1632,7 +1634,7 @@ export default class Optimizely implements Client { } if (this.odpManager) { - this.odpManager.updateSettings(projectConfig.odpIntegrationConfig); + this.odpManager.updateConfig(projectConfig.odpIntegrationConfig); } } @@ -1655,29 +1657,8 @@ export default class Optimizely implements Client { return; } - const odpEventType = type ?? ODP_DEFAULT_EVENT_TYPE; - - const odpIdentifiers = new Map(identifiers); - - if (identifiers && identifiers.size > 0) { - try { - identifiers.forEach((identifier_value, identifier_key) => { - // Catch for fs-user-id, FS-USER-ID, and FS_USER_ID and assign value to fs_user_id identifier. - if ( - FS_USER_ID_ALIAS === identifier_key.toLowerCase() || - ODP_USER_KEY.FS_USER_ID === identifier_key.toLowerCase() - ) { - odpIdentifiers.delete(identifier_key); - odpIdentifiers.set(ODP_USER_KEY.FS_USER_ID, identifier_value); - } - }); - } catch (e) { - this.logger.warn(LOG_MESSAGES.ODP_SEND_EVENT_IDENTIFIER_CONVERSION_FAILED); - } - } - try { - const odpEvent = new OdpEvent(odpEventType, action, odpIdentifiers, data); + const odpEvent = new OdpEvent(type || '', action, identifiers, data); this.odpManager.sendEvent(odpEvent); } catch (e) { this.logger.error(ERROR_MESSAGES.ODP_EVENT_FAILED, e); @@ -1724,16 +1705,11 @@ export default class Optimizely implements Client { * ODP Manager has not been instantiated yet for any reason. */ public getVuid(): string | undefined { - if (!this.odpManager) { - this.logger?.error('Unable to get VUID - ODP Manager is not instantiated yet.'); - return undefined; - } - - if (!this.odpManager.isVuidEnabled()) { - this.logger.log(LOG_LEVEL.WARNING, 'getVuid() unavailable for this platform', MODULE_NAME); + if (!this.vuidManager) { + this.logger?.error('Unable to get VUID - VuidManager is not available'); return undefined; } - return this.odpManager.getVuid(); + return this.vuidManager.getVuid(); } } diff --git a/lib/plugins/key_value_cache/browserAsyncStorageCache.ts b/lib/plugins/key_value_cache/browserAsyncStorageCache.ts deleted file mode 100644 index 508a9e5f4..000000000 --- a/lib/plugins/key_value_cache/browserAsyncStorageCache.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Copyright 2022-2024, 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 { tryWithLocalStorage } from '../../utils/local_storage/tryLocalStorage'; -import PersistentKeyValueCache from './persistentKeyValueCache'; -import { getLogger } from '../../modules/logging'; -import { ERROR_MESSAGES } from './../../utils/enums/index'; - -export default class BrowserAsyncStorageCache implements PersistentKeyValueCache { - logger = getLogger(); - - async contains(key: string): Promise { - return tryWithLocalStorage({ - browserCallback: (localStorage?: Storage) => { - return localStorage?.getItem(key) !== null; - }, - nonBrowserCallback: () => { - this.logger.error(ERROR_MESSAGES.LOCAL_STORAGE_DOES_NOT_EXIST); - return false; - }, - }); - } - - async get(key: string): Promise { - return tryWithLocalStorage({ - browserCallback: (localStorage?: Storage) => { - return (localStorage?.getItem(key) || undefined); - }, - nonBrowserCallback: () => { - this.logger.error(ERROR_MESSAGES.LOCAL_STORAGE_DOES_NOT_EXIST); - return undefined; - }, - }); - } - - async remove(key: string): Promise { - if (await this.contains(key)) { - tryWithLocalStorage({ - browserCallback: (localStorage?: Storage) => { - localStorage?.removeItem(key); - }, - nonBrowserCallback: () => { - this.logger.error(ERROR_MESSAGES.LOCAL_STORAGE_DOES_NOT_EXIST); - }, - }); - return true; - } else { - return false; - } - } - - async set(key: string, val: string): Promise { - return tryWithLocalStorage({ - browserCallback: (localStorage?: Storage) => { - localStorage?.setItem(key, val); - }, - nonBrowserCallback: () => { - this.logger.error(ERROR_MESSAGES.LOCAL_STORAGE_DOES_NOT_EXIST); - }, - }); - } -} diff --git a/lib/plugins/vuid_manager/index.ts b/lib/plugins/vuid_manager/index.ts deleted file mode 100644 index 8587724d6..000000000 --- a/lib/plugins/vuid_manager/index.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright 2022-2023, 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 { uuid } from '../../utils/fns'; -import PersistentKeyValueCache from '../key_value_cache/persistentKeyValueCache'; - -export interface IVuidManager { - readonly vuid: string; -} - -/** - * Manager for creating, persisting, and retrieving a Visitor Unique Identifier - */ -export class VuidManager implements IVuidManager { - /** - * Prefix used as part of the VUID format - * @public - * @readonly - */ - static readonly vuid_prefix: string = `vuid_`; - - /** - * Unique key used within the persistent value cache against which to - * store the VUID - * @private - */ - private _keyForVuid = 'optimizely-vuid'; - - /** - * Current VUID value being used - * @private - */ - private _vuid: string; - - /** - * Get the current VUID value being used - */ - get vuid(): string { - return this._vuid; - } - - private constructor() { - this._vuid = ''; - } - - /** - * Instance of the VUID Manager - * @private - */ - private static _instance: VuidManager; - - /** - * Gets the current instance of the VUID Manager, initializing if needed - * @param cache Caching mechanism to use for persisting the VUID outside working memory * - * @returns An instance of VuidManager - */ - static async instance(cache: PersistentKeyValueCache): Promise { - if (!this._instance) { - this._instance = new VuidManager(); - } - - if (!this._instance._vuid) { - await this._instance.load(cache); - } - - return this._instance; - } - - /** - * Attempts to load a VUID from persistent cache or generates a new VUID - * @param cache Caching mechanism to use for persisting the VUID outside working memory - * @returns Current VUID stored in the VuidManager - * @private - */ - private async load(cache: PersistentKeyValueCache): Promise { - const cachedValue = await cache.get(this._keyForVuid); - if (cachedValue && VuidManager.isVuid(cachedValue)) { - this._vuid = cachedValue; - } else { - this._vuid = this.makeVuid(); - await this.save(this._vuid, cache); - } - - return this._vuid; - } - - /** - * Creates a new VUID - * @returns A new visitor unique identifier - * @private - */ - private makeVuid(): string { - const maxLength = 32; // required by ODP server - - // make sure UUIDv4 is used (not UUIDv1 or UUIDv6) since the trailing 5 chars will be truncated. See TDD for details. - const uuidV4 = uuid(); - const formatted = uuidV4.replace(/-/g, '').toLowerCase(); - const vuidFull = `${VuidManager.vuid_prefix}${formatted}`; - - return vuidFull.length <= maxLength ? vuidFull : vuidFull.substring(0, maxLength); - } - - /** - * Saves a VUID to a persistent cache - * @param vuid VUID to be stored - * @param cache Caching mechanism to use for persisting the VUID outside working memory - * @private - */ - private async save(vuid: string, cache: PersistentKeyValueCache): Promise { - await cache.set(this._keyForVuid, vuid); - } - - /** - * Validates the format of a Visitor Unique Identifier - * @param vuid VistorId to check - * @returns *true* if the VisitorId is valid otherwise *false* for invalid - */ - static isVuid = (vuid: string): boolean => vuid?.startsWith(VuidManager.vuid_prefix) || false; - - /** - * Function used in unit testing to reset the VuidManager - * **Important**: This should not to be used in production code - * @private - */ - private static _reset(): void { - this._instance._vuid = ''; - } -} diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 2cab1c052..fa3579e69 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -25,24 +25,24 @@ import { NotificationCenter, DefaultNotificationCenter } from './notification_ce import { IOptimizelyUserContext as OptimizelyUserContext } from './optimizely_user_context'; -import { ICache } from './utils/lru_cache'; import { RequestHandler } from './utils/http_request_handler/http'; import { OptimizelySegmentOption } from './odp/segment_manager/optimizely_segment_option'; -import { IOdpSegmentApiManager } from './odp/segment_manager/odp_segment_api_manager'; -import { IOdpSegmentManager } from './odp/segment_manager/odp_segment_manager'; -import { IOdpEventApiManager } from './odp/event_manager/odp_event_api_manager'; -import { IOdpEventManager } from './odp/event_manager/odp_event_manager'; -import { IOdpManager } from './odp/odp_manager'; -import { IUserAgentParser } from './odp/ua_parser/user_agent_parser'; +import { OdpSegmentApiManager } from './odp/segment_manager/odp_segment_api_manager'; +import { OdpSegmentManager } from './odp/segment_manager/odp_segment_manager'; +import { DefaultOdpEventApiManager } from './odp/event_manager/odp_event_api_manager'; +import { OdpEventManager } from './odp/event_manager/odp_event_manager'; +import { OdpManager } from './odp/odp_manager'; import PersistentCache from './plugins/key_value_cache/persistentKeyValueCache'; import { ProjectConfig } from './project_config/project_config'; import { ProjectConfigManager } from './project_config/project_config_manager'; import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher'; import { EventProcessor } from './event_processor/event_processor'; +import { VuidManager } from './vuid/vuid_manager'; export { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher'; export { EventProcessor } from './event_processor/event_processor'; export { NotificationCenter } from './notification_center'; +export { VuidManager } from './vuid/vuid_manager'; export interface BucketerParams { experimentId: string; @@ -99,23 +99,6 @@ export interface DatafileOptions { datafileAccessToken?: string; } -export interface OdpOptions { - disabled?: boolean; - segmentsCache?: ICache; - segmentsCacheSize?: number; - segmentsCacheTimeout?: number; - segmentsApiTimeout?: number; - segmentsRequestHandler?: RequestHandler; - segmentManager?: IOdpSegmentManager; - eventFlushInterval?: number; - eventBatchSize?: number; - eventQueueSize?: number; - eventApiTimeout?: number; - eventRequestHandler?: RequestHandler; - eventManager?: IOdpEventManager; - userAgentParser?: IUserAgentParser; -} - export interface ListenerPayload { userId: string; attributes?: UserAttributes; @@ -282,8 +265,9 @@ export interface OptimizelyOptions { userProfileService?: UserProfileService | null; defaultDecideOptions?: OptimizelyDecideOption[]; isSsr?:boolean; - odpManager?: IOdpManager; + odpManager?: OdpManager; notificationCenter: DefaultNotificationCenter; + vuidManager?: VuidManager } /** @@ -386,7 +370,6 @@ export interface Config extends ConfigLite { // eventFlushInterval?: number; // Maximum time for an event to be enqueued // eventMaxQueueSize?: number; // Maximum size for the event queue sdkKey?: string; - odpOptions?: OdpOptions; persistentCacheProvider?: PersistentCacheProvider; } @@ -417,6 +400,8 @@ export interface ConfigLite { clientEngine?: string; clientVersion?: string; isSsr?: boolean; + odpManager?: OdpManager; + vuidManager?: VuidManager; } export type OptimizelyExperimentsMap = { @@ -539,12 +524,11 @@ export interface OptimizelyForcedDecision { // ODP Exports export { - ICache, RequestHandler, OptimizelySegmentOption, - IOdpSegmentApiManager, - IOdpSegmentManager, - IOdpEventApiManager, - IOdpEventManager, - IOdpManager, + OdpSegmentApiManager, + OdpSegmentManager, + DefaultOdpEventApiManager, + OdpEventManager, + OdpManager, }; diff --git a/lib/tests/mock/mock_repeater.ts b/lib/tests/mock/mock_repeater.ts index a93cbfa87..adf6baf83 100644 --- a/lib/tests/mock/mock_repeater.ts +++ b/lib/tests/mock/mock_repeater.ts @@ -20,7 +20,7 @@ import { AsyncTransformer } from '../../utils/type'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export const getMockRepeater = () => { const mock = { - isRunning: false, + running: false, handler: undefined as any, start: vi.fn(), stop: vi.fn(), @@ -36,8 +36,9 @@ export const getMockRepeater = () => { ret?.catch(() => {}); return ret; }, + isRunning: () => mock.running, }; - mock.start.mockImplementation(() => mock.isRunning = true); - mock.stop.mockImplementation(() => mock.isRunning = false); + mock.start.mockImplementation(() => mock.running = true); + mock.stop.mockImplementation(() => mock.running = false); return mock; } diff --git a/lib/tests/testUtils.ts b/lib/tests/testUtils.ts new file mode 100644 index 000000000..8bcd093f8 --- /dev/null +++ b/lib/tests/testUtils.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2024, 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 const exhaustMicrotasks = async (loop = 100): Promise => { + for(let i = 0; i < loop; i++) { + await Promise.resolve(); + } +}; + +export const wait = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/lib/utils/cache/in_memory_lru_cache.spec.ts b/lib/utils/cache/in_memory_lru_cache.spec.ts new file mode 100644 index 000000000..c6ab08780 --- /dev/null +++ b/lib/utils/cache/in_memory_lru_cache.spec.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2024, 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. + */ +import { expect, describe, it } from 'vitest'; +import { InMemoryLruCache } from './in_memory_lru_cache'; +import { wait } from '../../tests/testUtils'; + +describe('InMemoryLruCache', () => { + it('should save and get values correctly', () => { + const cache = new InMemoryLruCache(2); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBe(2); + }); + + it('should return undefined for non-existent keys', () => { + const cache = new InMemoryLruCache(2); + expect(cache.get('a')).toBe(undefined); + }); + + it('should return all keys in cache when getKeys is called', () => { + const cache = new InMemoryLruCache(20); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.set('d', 4); + expect(cache.getKeys()).toEqual(expect.arrayContaining(['d', 'c', 'b', 'a'])); + }); + + it('should evict least recently used keys when full', () => { + const cache = new InMemoryLruCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + expect(cache.get('a')).toBe(1); + expect(cache.getKeys()).toEqual(expect.arrayContaining(['a', 'c', 'b'])); + + // key use order is now a c b. next insert should evict b + cache.set('d', 4); + expect(cache.get('b')).toBe(undefined); + expect(cache.getKeys()).toEqual(expect.arrayContaining(['d', 'a', 'c'])); + + // key use order is now d a c. setting c should put it at the front + cache.set('c', 5); + + // key use order is now c d a. next insert should evict a + cache.set('e', 6); + expect(cache.get('a')).toBe(undefined); + expect(cache.getKeys()).toEqual(expect.arrayContaining(['e', 'c', 'd'])); + + // key use order is now e c d. reading d should put it at the front + expect(cache.get('d')).toBe(4); + + // key use order is now d e c. next insert should evict c + cache.set('f', 7); + expect(cache.get('c')).toBe(undefined); + expect(cache.getKeys()).toEqual(expect.arrayContaining(['f', 'd', 'e'])); + }); + + it('should not return expired values when get is called', async () => { + const cache = new InMemoryLruCache(2, 100); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBe(2); + + await wait(150); + expect(cache.get('a')).toBe(undefined); + expect(cache.get('b')).toBe(undefined); + }); + + it('should remove values correctly', () => { + const cache = new InMemoryLruCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.remove('a'); + expect(cache.get('a')).toBe(undefined); + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + }); + + it('should clear all values correctly', () => { + const cache = new InMemoryLruCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.clear(); + expect(cache.get('a')).toBe(undefined); + expect(cache.get('b')).toBe(undefined); + }); + + it('should return correct values when getBatched is called', () => { + const cache = new InMemoryLruCache(2); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.getBatched(['a', 'b', 'c'])).toEqual([1, 2, undefined]); + }); + + it('should not return expired values when getBatched is called', async () => { + const cache = new InMemoryLruCache(2, 100); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.getBatched(['a', 'b'])).toEqual([1, 2]); + + await wait(150); + expect(cache.getBatched(['a', 'b'])).toEqual([undefined, undefined]); + }); +}); diff --git a/lib/utils/cache/in_memory_lru_cache.ts b/lib/utils/cache/in_memory_lru_cache.ts new file mode 100644 index 000000000..1b4d3a7bd --- /dev/null +++ b/lib/utils/cache/in_memory_lru_cache.ts @@ -0,0 +1,78 @@ +/** + * Copyright 2022-2024, 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. + */ + +import { Maybe } from "../type"; +import { SyncCache } from "./cache"; + +type CacheElement = { + value: V; + expiresAt?: number; +}; + +export class InMemoryLruCache implements SyncCache { + public operation = 'sync' as const; + private data: Map> = new Map(); + private maxSize: number; + private ttl?: number; + + constructor(maxSize: number, ttl?: number) { + this.maxSize = maxSize; + this.ttl = ttl; + } + + get(key: string): Maybe { + const element = this.data.get(key); + if (!element) return undefined; + this.data.delete(key); + + if (element.expiresAt && element.expiresAt <= Date.now()) { + return undefined; + } + + this.data.set(key, element); + return element.value; + } + + set(key: string, value: V): void { + this.data.delete(key); + + if (this.data.size === this.maxSize) { + const firstMapEntryKey = this.data.keys().next().value; + this.data.delete(firstMapEntryKey!); + } + + this.data.set(key, { + value, + expiresAt: this.ttl ? Date.now() + this.ttl : undefined, + }); + } + + remove(key: string): void { + this.data.delete(key); + } + + clear(): void { + this.data.clear(); + } + + getKeys(): string[] { + return Array.from(this.data.keys()); + } + + getBatched(keys: string[]): Maybe[] { + return keys.map((key) => this.get(key)); + } +} diff --git a/lib/utils/enums/index.ts b/lib/utils/enums/index.ts index 10a5deb3f..551fa6b98 100644 --- a/lib/utils/enums/index.ts +++ b/lib/utils/enums/index.ts @@ -291,28 +291,6 @@ export { NOTIFICATION_TYPES } from '../../notification_center/type'; * Default milliseconds before request timeout */ export const REQUEST_TIMEOUT_MS = 60 * 1000; // 1 minute -export const REQUEST_TIMEOUT_ODP_SEGMENTS_MS = 10 * 1000; // 10 secs -export const REQUEST_TIMEOUT_ODP_EVENTS_MS = 10 * 1000; // 10 secs -/** - * ODP User Key Options - */ -export enum ODP_USER_KEY { - VUID = 'vuid', - FS_USER_ID = 'fs_user_id', -} -/** - * Alias for fs_user_id to catch for and automatically convert to fs_user_id - */ -export const FS_USER_ID_ALIAS = 'fs-user-id'; -export const ODP_DEFAULT_EVENT_TYPE = 'fullstack'; - -/** - * ODP Event Action Options - */ -export enum ODP_EVENT_ACTION { - IDENTIFIED = 'identified', - INITIALIZED = 'client_initialized', -} diff --git a/lib/utils/fns/index.ts b/lib/utils/fns/index.ts index 98606a77a..e53402a22 100644 --- a/lib/utils/fns/index.ts +++ b/lib/utils/fns/index.ts @@ -57,6 +57,7 @@ export function keyBy(arr: K[], key: string): { [key: string]: K } { }); } + function isNumber(value: unknown): boolean { return typeof value === 'number'; } diff --git a/lib/utils/lru_cache/browser_lru_cache.ts b/lib/utils/lru_cache/browser_lru_cache.ts deleted file mode 100644 index ca5d4cb92..000000000 --- a/lib/utils/lru_cache/browser_lru_cache.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright 2022-2023, 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. - */ - -import LRUCache, { ISegmentsCacheConfig } from './lru_cache'; - -export interface BrowserLRUCacheConfig { - maxSize?: number; - timeout?: number; -} - -export const BrowserLRUCacheConfig: ISegmentsCacheConfig = { - DEFAULT_CAPACITY: 100, - DEFAULT_TIMEOUT_SECS: 600, -}; - -export class BrowserLRUCache extends LRUCache { - constructor(config?: BrowserLRUCacheConfig) { - super({ - maxSize: config?.maxSize?? BrowserLRUCacheConfig.DEFAULT_CAPACITY, - timeout: config?.timeout?? BrowserLRUCacheConfig.DEFAULT_TIMEOUT_SECS * 1000, - }); - } -} diff --git a/lib/utils/lru_cache/cache_element.tests.ts b/lib/utils/lru_cache/cache_element.tests.ts deleted file mode 100644 index dfba16fa7..000000000 --- a/lib/utils/lru_cache/cache_element.tests.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 - * - * 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. - */ - -import { assert } from 'chai'; -import { CacheElement } from './cache_element'; - -const sleep = async (ms: number) => { - return await new Promise(r => setTimeout(r, ms)); -}; - -describe('/odp/lru_cache/CacheElement', () => { - let element: CacheElement; - - beforeEach(() => { - element = new CacheElement('foo'); - }); - - it('should initialize a valid CacheElement', () => { - assert.exists(element); - assert.equal(element.value, 'foo'); - assert.isNotNull(element.time); - assert.doesNotThrow(() => element.is_stale(0)); - }); - - it('should return false if not stale based on timeout', () => { - const timeoutLong = 1000; - assert.equal(element.is_stale(timeoutLong), false); - }); - - it('should return false if not stale because timeout is less than or equal to 0', () => { - const timeoutNone = 0; - assert.equal(element.is_stale(timeoutNone), false); - }); - - it('should return true if stale based on timeout', async () => { - await sleep(100); - const timeoutShort = 1; - assert.equal(element.is_stale(timeoutShort), true); - }); -}); diff --git a/lib/utils/lru_cache/cache_element.ts b/lib/utils/lru_cache/cache_element.ts deleted file mode 100644 index c286aab7a..000000000 --- a/lib/utils/lru_cache/cache_element.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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 - * - * 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. - */ - -/** - * CacheElement represents an individual generic item within the LRUCache - */ -export class CacheElement { - private _value: V | null; - private _time: number; - - get value(): V | null { - return this._value; - } - get time(): number { - return this._time; - } - - constructor(value: V | null = null) { - this._value = value; - this._time = Date.now(); - } - - public is_stale(timeout: number): boolean { - if (timeout <= 0) return false; - return Date.now() - this._time >= timeout; - } -} - -export default CacheElement; diff --git a/lib/utils/lru_cache/lru_cache.tests.ts b/lib/utils/lru_cache/lru_cache.tests.ts deleted file mode 100644 index 4c9de8d1a..000000000 --- a/lib/utils/lru_cache/lru_cache.tests.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * 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 - * - * 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. - */ - -import { assert } from 'chai'; -import { LRUCache } from './lru_cache'; -import { BrowserLRUCache } from './browser_lru_cache'; -import { ServerLRUCache } from './server_lru_cache'; - -const sleep = async (ms: number) => { - return await new Promise(r => setTimeout(r, ms)); -}; - -describe('/lib/core/odp/lru_cache (Default)', () => { - let cache: LRUCache; - - describe('LRU Cache > Initialization', () => { - it('should successfully create a new cache with maxSize > 0 and timeout > 0', () => { - cache = new LRUCache({ - maxSize: 1000, - timeout: 2000, - }); - - assert.exists(cache); - - assert.equal(cache.maxSize, 1000); - assert.equal(cache.timeout, 2000); - }); - - it('should successfully create a new cache with maxSize == 0 and timeout == 0', () => { - cache = new LRUCache({ - maxSize: 0, - timeout: 0, - }); - - assert.exists(cache); - - assert.equal(cache.maxSize, 0); - assert.equal(cache.timeout, 0); - }); - }); - - describe('LRU Cache > Save & Lookup', () => { - const maxCacheSize = 2; - - beforeEach(() => { - cache = new LRUCache({ - maxSize: maxCacheSize, - timeout: 1000, - }); - }); - - it('should have no values in the cache upon initialization', () => { - assert.isNull(cache.peek(1)); - }); - - it('should save keys and values of any valid type', () => { - cache.save({ key: 'a', value: 1 }); // { a: 1 } - assert.equal(cache.peek('a'), 1); - - cache.save({ key: 2, value: 'b' }); // { a: 1, 2: 'b' } - assert.equal(cache.peek(2), 'b'); - - const foo = Symbol('foo'); - const bar = {}; - cache.save({ key: foo, value: bar }); // { 2: 'b', Symbol('foo'): {} } - assert.deepEqual({}, cache.peek(foo)); - }); - - it('should save values up to its maxSize', () => { - cache.save({ key: 'a', value: 1 }); // { a: 1 } - assert.equal(cache.peek('a'), 1); - - cache.save({ key: 'b', value: 2 }); // { a: 1, b: 2 } - assert.equal(cache.peek('a'), 1); - assert.equal(cache.peek('b'), 2); - - cache.save({ key: 'c', value: 3 }); // { b: 2, c: 3 } - assert.equal(cache.peek('a'), null); - assert.equal(cache.peek('b'), 2); - assert.equal(cache.peek('c'), 3); - }); - - it('should override values of matching keys when saving', () => { - cache.save({ key: 'a', value: 1 }); // { a: 1 } - assert.equal(cache.peek('a'), 1); - - cache.save({ key: 'a', value: 2 }); // { a: 2 } - assert.equal(cache.peek('a'), 2); - - cache.save({ key: 'a', value: 3 }); // { a: 3 } - assert.equal(cache.peek('a'), 3); - }); - - it('should update cache accordingly when using lookup/peek', () => { - assert.isNull(cache.lookup(3)); - - cache.save({ key: 'b', value: 201 }); // { b: 201 } - cache.save({ key: 'a', value: 101 }); // { b: 201, a: 101 } - - assert.equal(cache.lookup('b'), 201); // { a: 101, b: 201 } - - cache.save({ key: 'c', value: 302 }); // { b: 201, c: 302 } - - assert.isNull(cache.peek(1)); - assert.equal(cache.peek('b'), 201); - assert.equal(cache.peek('c'), 302); - assert.equal(cache.lookup('c'), 302); // { b: 201, c: 302 } - - cache.save({ key: 'a', value: 103 }); // { c: 302, a: 103 } - assert.equal(cache.peek('a'), 103); - assert.isNull(cache.peek('b')); - assert.equal(cache.peek('c'), 302); - }); - }); - - describe('LRU Cache > Size', () => { - it('should keep LRU Cache map size capped at cache.capacity', () => { - const maxCacheSize = 2; - - cache = new LRUCache({ - maxSize: maxCacheSize, - timeout: 1000, - }); - - cache.save({ key: 'a', value: 1 }); // { a: 1 } - cache.save({ key: 'b', value: 2 }); // { a: 1, b: 2 } - - assert.equal(cache.map.size, maxCacheSize); - assert.equal(cache.map.size, cache.maxSize); - }); - - it('should not save to cache if maxSize is 0', () => { - cache = new LRUCache({ - maxSize: 0, - timeout: 1000, - }); - - assert.isNull(cache.lookup('a')); - cache.save({ key: 'a', value: 100 }); - assert.isNull(cache.lookup('a')); - }); - - it('should not save to cache if maxSize is negative', () => { - cache = new LRUCache({ - maxSize: -500, - timeout: 1000, - }); - - assert.isNull(cache.lookup('a')); - cache.save({ key: 'a', value: 100 }); - assert.isNull(cache.lookup('a')); - }); - }); - - describe('LRU Cache > Timeout', () => { - it('should discard stale entries in the cache on peek/lookup when timeout is greater than 0', async () => { - const maxTimeout = 100; - - cache = new LRUCache({ - maxSize: 1000, - timeout: maxTimeout, - }); - - cache.save({ key: 'a', value: 100 }); // { a: 100 } - cache.save({ key: 'b', value: 200 }); // { a: 100, b: 200 } - cache.save({ key: 'c', value: 300 }); // { a: 100, b: 200, c: 300 } - - assert.equal(cache.peek('a'), 100); - assert.equal(cache.peek('b'), 200); - assert.equal(cache.peek('c'), 300); - - await sleep(150); - - assert.isNull(cache.lookup('a')); - assert.isNull(cache.lookup('b')); - assert.isNull(cache.lookup('c')); - - cache.save({ key: 'd', value: 400 }); // { d: 400 } - cache.save({ key: 'a', value: 101 }); // { d: 400, a: 101 } - - assert.equal(cache.lookup('a'), 101); // { d: 400, a: 101 } - assert.equal(cache.lookup('d'), 400); // { a: 101, d: 400 } - }); - - it('should never have stale entries if timeout is 0', async () => { - const maxTimeout = 0; - - cache = new LRUCache({ - maxSize: 1000, - timeout: maxTimeout, - }); - - cache.save({ key: 'a', value: 100 }); // { a: 100 } - cache.save({ key: 'b', value: 200 }); // { a: 100, b: 200 } - - await sleep(100); - assert.equal(cache.lookup('a'), 100); - assert.equal(cache.lookup('b'), 200); - }); - - it('should never have stale entries if timeout is less than 0', async () => { - const maxTimeout = -500; - - cache = new LRUCache({ - maxSize: 1000, - timeout: maxTimeout, - }); - - cache.save({ key: 'a', value: 100 }); // { a: 100 } - cache.save({ key: 'b', value: 200 }); // { a: 100, b: 200 } - - await sleep(100); - assert.equal(cache.lookup('a'), 100); - assert.equal(cache.lookup('b'), 200); - }); - }); - - describe('LRU Cache > Reset', () => { - it('should be able to reset the cache', async () => { - cache = new LRUCache({ maxSize: 2, timeout: 100 }); - cache.save({ key: 'a', value: 100 }); // { a: 100 } - cache.save({ key: 'b', value: 200 }); // { a: 100, b: 200 } - - await sleep(0); - - assert.equal(cache.map.size, 2); - cache.reset(); // { } - - await sleep(150); - - assert.equal(cache.map.size, 0); - - it('should be fully functional after resetting the cache', () => { - cache.save({ key: 'c', value: 300 }); // { c: 300 } - cache.save({ key: 'd', value: 400 }); // { c: 300, d: 400 } - assert.isNull(cache.peek('b')); - assert.equal(cache.peek('c'), 300); - assert.equal(cache.peek('d'), 400); - - cache.save({ key: 'a', value: 500 }); // { d: 400, a: 500 } - cache.save({ key: 'b', value: 600 }); // { a: 500, b: 600 } - assert.isNull(cache.peek('c')); - assert.equal(cache.peek('a'), 500); - assert.equal(cache.peek('b'), 600); - - const _ = cache.lookup('a'); // { b: 600, a: 500 } - assert.equal(500, _); - - cache.save({ key: 'c', value: 700 }); // { a: 500, c: 700 } - assert.isNull(cache.peek('b')); - assert.equal(cache.peek('a'), 500); - assert.equal(cache.peek('c'), 700); - }); - }); - }); -}); - -describe('/lib/core/odp/lru_cache (Client)', () => { - let cache: BrowserLRUCache; - - it('should create and test the default client LRU Cache', () => { - cache = new BrowserLRUCache(); - assert.exists(cache); - assert.isNull(cache.lookup('a')); - assert.equal(cache.maxSize, 100); - assert.equal(cache.timeout, 600 * 1000); - - cache.save({ key: 'a', value: 100 }); - cache.save({ key: 'b', value: 200 }); - cache.save({ key: 'c', value: 300 }); - assert.equal(cache.map.size, 3); - assert.equal(cache.peek('a'), 100); - assert.equal(cache.lookup('b'), 200); - assert.deepEqual(cache.map.keys().next().value, 'a'); - }); -}); - -describe('/lib/core/odp/lru_cache (Server)', () => { - let cache: ServerLRUCache; - - it('should create and test the default server LRU Cache', () => { - cache = new ServerLRUCache(); - assert.exists(cache); - assert.isNull(cache.lookup('a')); - assert.equal(cache.maxSize, 10000); - assert.equal(cache.timeout, 600 * 1000); - - cache.save({ key: 'a', value: 100 }); - cache.save({ key: 'b', value: 200 }); - cache.save({ key: 'c', value: 300 }); - assert.equal(cache.map.size, 3); - assert.equal(cache.peek('a'), 100); - assert.equal(cache.lookup('b'), 200); - assert.deepEqual(cache.map.keys().next().value, 'a'); - }); -}); diff --git a/lib/utils/lru_cache/lru_cache.ts b/lib/utils/lru_cache/lru_cache.ts deleted file mode 100644 index 0e8be1d8c..000000000 --- a/lib/utils/lru_cache/lru_cache.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Copyright 2022-2023, 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. - */ - -import { getLogger } from '../../modules/logging'; -import CacheElement from './cache_element'; - -export interface LRUCacheConfig { - maxSize: number; - timeout: number; -} - -export interface ICache { - lookup(key: K): V | null; - save({ key, value }: { key: K; value: V }): void; - reset(): void; -} - -/** - * Least-Recently Used Cache (LRU Cache) Implementation with Generic Key-Value Pairs - * Analogous to a Map that has a specified max size and a timeout per element. - * - Removes the least-recently used element from the cache if max size exceeded. - * - Removes stale elements (entries older than their timeout) from the cache. - */ -export class LRUCache implements ICache { - private _map: Map> = new Map(); - private _maxSize; // Defines maximum size of _map - private _timeout; // Milliseconds each entry has before it becomes stale - - get map(): Map> { - return this._map; - } - - get maxSize(): number { - return this._maxSize; - } - - get timeout(): number { - return this._timeout; - } - - constructor({ maxSize, timeout }: LRUCacheConfig) { - const logger = getLogger(); - - logger.debug(`Provisioning cache with maxSize of ${maxSize}`); - logger.debug(`Provisioning cache with timeout of ${timeout}`); - - this._maxSize = maxSize; - this._timeout = timeout; - } - - /** - * Returns a valid, non-stale value from LRU Cache based on an input key. - * Additionally moves the element to the end of the cache and removes from cache if stale. - */ - lookup(key: K): V | null { - if (this._maxSize <= 0) { - return null; - } - - const element: CacheElement | undefined = this._map.get(key); - - if (!element) return null; - - if (element.is_stale(this._timeout)) { - this._map.delete(key); - return null; - } - - this._map.delete(key); - this._map.set(key, element); - - return element.value; - } - - /** - * Inserts/moves an input key-value pair to the end of the LRU Cache. - * Removes the least-recently used element if the cache exceeds it's maxSize. - */ - save({ key, value }: { key: K; value: V }): void { - if (this._maxSize <= 0) return; - - const element: CacheElement | undefined = this._map.get(key); - if (element) this._map.delete(key); - this._map.set(key, new CacheElement(value)); - - if (this._map.size > this._maxSize) { - const firstMapEntryKey = this._map.keys().next().value; - this._map.delete(firstMapEntryKey); - } - } - - /** - * Clears the LRU Cache - */ - reset(): void { - if (this._maxSize <= 0) return; - - this._map.clear(); - } - - /** - * Reads value from specified key without moving elements in the LRU Cache. - * @param {K} key - */ - peek(key: K): V | null { - if (this._maxSize <= 0) return null; - - const element: CacheElement | undefined = this._map.get(key); - - return element?.value ?? null; - } -} - -export interface ISegmentsCacheConfig { - DEFAULT_CAPACITY: number; - DEFAULT_TIMEOUT_SECS: number; -} - -export default LRUCache; diff --git a/lib/utils/lru_cache/server_lru_cache.ts b/lib/utils/lru_cache/server_lru_cache.ts deleted file mode 100644 index 110d9b28e..000000000 --- a/lib/utils/lru_cache/server_lru_cache.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright 2022-2023, 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. - */ - -import LRUCache, { ISegmentsCacheConfig } from './lru_cache'; - -export interface ServerLRUCacheConfig { - maxSize?: number; - timeout?: number; -} - -export const ServerLRUCacheConfig: ISegmentsCacheConfig = { - DEFAULT_CAPACITY: 10000, - DEFAULT_TIMEOUT_SECS: 600, -}; - -export class ServerLRUCache extends LRUCache { - constructor(config?: ServerLRUCacheConfig) { - super({ - maxSize: config?.maxSize?? ServerLRUCacheConfig.DEFAULT_CAPACITY, - timeout: config?.timeout?? ServerLRUCacheConfig.DEFAULT_TIMEOUT_SECS * 1000, - }); - } -} diff --git a/lib/utils/repeater/repeater.ts b/lib/utils/repeater/repeater.ts index 1425db431..9f307ab95 100644 --- a/lib/utils/repeater/repeater.ts +++ b/lib/utils/repeater/repeater.ts @@ -31,6 +31,7 @@ export interface Repeater { stop(): void; reset(): void; setTask(task: AsyncTransformer): void; + isRunning(): boolean; } export interface BackoffController { @@ -74,13 +75,17 @@ export class IntervalRepeater implements Repeater { private interval: number; private failureCount = 0; private backoffController?: BackoffController; - private isRunning = false; + private running = false; constructor(interval: number, backoffController?: BackoffController) { this.interval = interval; this.backoffController = backoffController; } + isRunning(): boolean { + return this.running; + } + private handleSuccess() { this.failureCount = 0; this.backoffController?.reset(); @@ -94,7 +99,7 @@ export class IntervalRepeater implements Repeater { } private setTimer(timeout: number) { - if (!this.isRunning){ + if (!this.running){ return; } this.timeoutId = setTimeout(this.executeTask.bind(this), timeout); @@ -111,7 +116,7 @@ export class IntervalRepeater implements Repeater { } start(immediateExecution?: boolean): void { - this.isRunning = true; + this.running = true; if(immediateExecution) { scheduleMicrotask(this.executeTask.bind(this)); } else { @@ -120,7 +125,7 @@ export class IntervalRepeater implements Repeater { } stop(): void { - this.isRunning = false; + this.running = false; clearInterval(this.timeoutId); } diff --git a/lib/vuid/vuid.spec.ts b/lib/vuid/vuid.spec.ts new file mode 100644 index 000000000..0a0790b59 --- /dev/null +++ b/lib/vuid/vuid.spec.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2024, 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 { describe, expect, it } from 'vitest'; + +import { isVuid, makeVuid, VUID_MAX_LENGTH } from './vuid'; + +describe('isVuid', () => { + it('should return true if and only if the value strats with the VUID_PREFIX and is longer than vuid_prefix', () => { + expect(isVuid('vuid_a')).toBe(true); + expect(isVuid('vuid_123')).toBe(true); + expect(isVuid('vuid_')).toBe(false); + expect(isVuid('vuid')).toBe(false); + expect(isVuid('vui')).toBe(false); + expect(isVuid('vu_123')).toBe(false); + expect(isVuid('123')).toBe(false); + }) +}); + +describe('makeVuid', () => { + it('should return a string that is a valid vuid and whose length is within VUID_MAX_LENGTH', () => { + const vuid = makeVuid(); + expect(isVuid(vuid)).toBe(true); + expect(vuid.length).toBeLessThanOrEqual(VUID_MAX_LENGTH); + }); +}); diff --git a/lib/vuid/vuid.ts b/lib/vuid/vuid.ts new file mode 100644 index 000000000..d335c329d --- /dev/null +++ b/lib/vuid/vuid.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2024, 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 { v4 as uuidV4 } from 'uuid'; + +export const VUID_PREFIX = `vuid_`; +export const VUID_MAX_LENGTH = 32; + +export const isVuid = (vuid: string): boolean => vuid.startsWith(VUID_PREFIX) && vuid.length > VUID_PREFIX.length; + +export const makeVuid = (): string => { + // make sure UUIDv4 is used (not UUIDv1 or UUIDv6) since the trailing 5 chars will be truncated. See TDD for details. + const uuid = uuidV4(); + const formatted = uuid.replace(/-/g, ''); + const vuidFull = `${VUID_PREFIX}${formatted}`; + + return vuidFull.length <= VUID_MAX_LENGTH ? vuidFull : vuidFull.substring(0, VUID_MAX_LENGTH); +}; diff --git a/lib/vuid/vuid_manager.spec.ts b/lib/vuid/vuid_manager.spec.ts new file mode 100644 index 000000000..5a4713d68 --- /dev/null +++ b/lib/vuid/vuid_manager.spec.ts @@ -0,0 +1,230 @@ +/** + * Copyright 2022, 2024, 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 { describe, it, expect, vi } from 'vitest'; + +import { DefaultVuidManager, VuidCacheManager } from './vuid_manager'; + +import { getMockAsyncCache } from '../tests/mock/mock_cache'; +import { isVuid } from './vuid'; +import { resolvablePromise } from '../utils/promise/resolvablePromise'; +import { exhaustMicrotasks } from '../tests/testUtils'; +import { get } from 'http'; + +const vuidCacheKey = 'optimizely-vuid'; + +describe('VuidCacheManager', () => { + it('should remove vuid from cache', async () => { + const cache = getMockAsyncCache(); + await cache.set(vuidCacheKey, 'vuid_valid'); + + const manager = new VuidCacheManager(cache); + await manager.remove(); + const vuidInCache = await cache.get(vuidCacheKey); + expect(vuidInCache).toBeUndefined(); + }); + + it('should create and save a new vuid if there is no vuid in cache', async () => { + const cache = getMockAsyncCache(); + + const manager = new VuidCacheManager(cache); + const vuid = await manager.load(); + const vuidInCache = await cache.get(vuidCacheKey); + expect(vuidInCache).toBe(vuid); + expect(isVuid(vuid!)).toBe(true); + }); + + it('should create and save a new vuid if old VUID from cache is not valid', async () => { + const cache = getMockAsyncCache(); + await cache.set(vuidCacheKey, 'invalid-vuid'); + + const manager = new VuidCacheManager(cache); + const vuid = await manager.load(); + const vuidInCache = await cache.get(vuidCacheKey); + expect(vuidInCache).toBe(vuid); + expect(isVuid(vuid!)).toBe(true); + }); + + it('should return the same vuid without modifying the cache after creating a new vuid', async () => { + const cache = getMockAsyncCache(); + + const manager = new VuidCacheManager(cache); + const vuid1 = await manager.load(); + const vuid2 = await manager.load(); + expect(vuid1).toBe(vuid2); + const vuidInCache = await cache.get(vuidCacheKey); + expect(vuidInCache).toBe(vuid1); + }); + + it('should use the vuid in cache if available', async () => { + const cache = getMockAsyncCache(); + await cache.set(vuidCacheKey, 'vuid_valid'); + + const manager = new VuidCacheManager(cache); + const vuid1 = await manager.load(); + const vuid2 = await manager.load(); + expect(vuid1).toBe('vuid_valid'); + expect(vuid2).toBe('vuid_valid'); + const vuidInCache = await cache.get(vuidCacheKey); + expect(vuidInCache).toBe('vuid_valid'); + }); + + it('should use the new cache after setCache is called', async () => { + const cache1 = getMockAsyncCache(); + const cache2 = getMockAsyncCache(); + + await cache1.set(vuidCacheKey, 'vuid_123'); + await cache2.set(vuidCacheKey, 'vuid_456'); + + const manager = new VuidCacheManager(cache1); + const vuid1 = await manager.load(); + expect(vuid1).toBe('vuid_123'); + + manager.setCache(cache2); + await manager.load(); + const vuid2 = await cache2.get(vuidCacheKey); + expect(vuid2).toBe('vuid_456'); + + await manager.remove(); + const vuidInCache = await cache2.get(vuidCacheKey); + expect(vuidInCache).toBeUndefined(); + }); + + it('should sequence remove and load calls', async() => { + const cache = getMockAsyncCache(); + const removeSpy = vi.spyOn(cache, 'remove'); + const getSpy = vi.spyOn(cache, 'get'); + const setSpy = vi.spyOn(cache, 'set'); + + const removePromise = resolvablePromise(); + removeSpy.mockReturnValueOnce(removePromise.promise); + + const getPromise = resolvablePromise(); + getSpy.mockReturnValueOnce(getPromise.promise); + + const setPromise = resolvablePromise(); + setSpy.mockReturnValueOnce(setPromise.promise); + + const manager = new VuidCacheManager(cache); + + // this should try to remove from cache, which should stay pending + const call1 = manager.remove(); + + // this should try to get the vuid from cache + const call2 = manager.load(); + + // this should again try to remove vuid + const call3 = manager.remove(); + + await exhaustMicrotasks(); + + expect(removeSpy).toHaveBeenCalledTimes(1); // from the first manager.remove call + expect(getSpy).not.toHaveBeenCalled(); + + // this will resolve the first manager.remove call + removePromise.resolve(true); + await exhaustMicrotasks(); + await expect(call1).resolves.not.toThrow(); + + // this get call is from the load call + expect(getSpy).toHaveBeenCalledTimes(1); + await exhaustMicrotasks(); + + // as the get call is pending, remove call from the second manager.remove call should not yet happen + expect(removeSpy).toHaveBeenCalledTimes(1); + + // this should fail the load call, allowing the second remnove call to proceed + getPromise.reject(new Error('get failed')); + await exhaustMicrotasks(); + await expect(call2).rejects.toThrow(); + + expect(removeSpy).toHaveBeenCalledTimes(2); + }); +}); + +describe('DefaultVuidManager', () => { + const getMockCacheManager = () => ({ + remove: vi.fn(), + load: vi.fn(), + setCache: vi.fn(), + }); + + it('should return undefined for getVuid() before initialization', async () => { + const manager = new DefaultVuidManager({ + vuidCache: getMockAsyncCache(), + vuidCacheManager: getMockCacheManager() as unknown as VuidCacheManager, + enableVuid: true + }); + + expect(manager.getVuid()).toBeUndefined(); + }); + + it('should set the cache on vuidCacheManager', async () => { + const vuidCacheManager = getMockCacheManager(); + + const cache = getMockAsyncCache(); + + const manager = new DefaultVuidManager({ + vuidCache: cache, + vuidCacheManager: vuidCacheManager as unknown as VuidCacheManager, + enableVuid: false + }); + + await manager.initialize(); + expect(vuidCacheManager.setCache).toHaveBeenCalledWith(cache); + }); + + it('should call remove on VuidCacheManager if enableVuid is false', async () => { + const vuidCacheManager = getMockCacheManager(); + + const manager = new DefaultVuidManager({ + vuidCache: getMockAsyncCache(), + vuidCacheManager: vuidCacheManager as unknown as VuidCacheManager, + enableVuid: false + }); + + await manager.initialize(); + expect(vuidCacheManager.remove).toHaveBeenCalled(); + }); + + it('should return undefined for getVuid() after initialization if enableVuid is false', async () => { + const vuidCacheManager = getMockCacheManager(); + + const manager = new DefaultVuidManager({ + vuidCache: getMockAsyncCache(), + vuidCacheManager: vuidCacheManager as unknown as VuidCacheManager, + enableVuid: false + }); + + await manager.initialize(); + expect(manager.getVuid()).toBeUndefined(); + }); + + it('should load vuid using VuidCacheManger if enableVuid=true', async () => { + const vuidCacheManager = getMockCacheManager(); + vuidCacheManager.load.mockResolvedValue('vuid_valid'); + + const manager = new DefaultVuidManager({ + vuidCache: getMockAsyncCache(), + vuidCacheManager: vuidCacheManager as unknown as VuidCacheManager, + enableVuid: true + }); + + await manager.initialize(); + expect(vuidCacheManager.load).toHaveBeenCalled(); + expect(manager.getVuid()).toBe('vuid_valid'); + }); +}); diff --git a/lib/vuid/vuid_manager.ts b/lib/vuid/vuid_manager.ts new file mode 100644 index 000000000..8de680609 --- /dev/null +++ b/lib/vuid/vuid_manager.ts @@ -0,0 +1,132 @@ +/** + * Copyright 2022-2024, 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 { LoggerFacade } from '../modules/logging'; +import { Cache } from '../utils/cache/cache'; +import { AsyncProducer, Maybe } from '../utils/type'; +import { isVuid, makeVuid } from './vuid'; + +export interface VuidManager { + getVuid(): Maybe; + isVuidEnabled(): boolean; + initialize(): Promise; +} + +export class VuidCacheManager { + private logger?: LoggerFacade; + private vuidCacheKey = 'optimizely-vuid'; + private cache?: Cache; + // if this value is not undefined, this means the same value is in the cache. + // if this is undefined, it could either mean that there is no value in the cache + // or that there is a value in the cache but it has not been loaded yet or failed + // to load. + private vuid?: string; + private waitPromise: Promise = Promise.resolve(); + + constructor(cache?: Cache, logger?: LoggerFacade) { + this.cache = cache; + this.logger = logger; + } + + setCache(cache: Cache): void { + this.cache = cache; + this.vuid = undefined; + } + + setLogger(logger: LoggerFacade): void { + this.logger = logger; + } + + private async serialize(fn: AsyncProducer): Promise { + const resultPromise = this.waitPromise.then(fn, fn); + this.waitPromise = resultPromise.catch(() => {}); + return resultPromise; + } + + async remove(): Promise { + const removeFn = async () => { + if (!this.cache) { + return; + } + this.vuid = undefined; + await this.cache.remove(this.vuidCacheKey); + } + + return this.serialize(removeFn); + } + + async load(): Promise> { + if (this.vuid) { + return this.vuid; + } + + const loadFn = async () => { + if (!this.cache) { + return; + } + const cachedValue = await this.cache.get(this.vuidCacheKey); + if (cachedValue && isVuid(cachedValue)) { + this.vuid = cachedValue; + return this.vuid; + } + const newVuid = makeVuid(); + await this.cache.set(this.vuidCacheKey, newVuid); + this.vuid = newVuid; + return newVuid; + } + return this.serialize(loadFn); + } +} + +export type VuidManagerConfig = { + enableVuid?: boolean; + vuidCache: Cache; + vuidCacheManager: VuidCacheManager; +} + +export class DefaultVuidManager implements VuidManager { + private vuidCacheManager: VuidCacheManager; + private vuid?: string; + private vuidCache: Cache; + private vuidEnabled = false; + + constructor(config: VuidManagerConfig) { + this.vuidCacheManager = config.vuidCacheManager; + this.vuidEnabled = config.enableVuid || false; + this.vuidCache = config.vuidCache; + } + + getVuid(): Maybe { + return this.vuid; + } + + isVuidEnabled(): boolean { + return this.vuidEnabled; + } + + /** + * initializes the VuidManager + * @returns Promise that resolves when the VuidManager is initialized + */ + async initialize(): Promise { + this.vuidCacheManager.setCache(this.vuidCache); + if (!this.vuidEnabled) { + await this.vuidCacheManager.remove(); + return; + } + + this.vuid = await this.vuidCacheManager.load(); + } +} diff --git a/lib/vuid/vuid_manager_factory.browser.spec.ts b/lib/vuid/vuid_manager_factory.browser.spec.ts new file mode 100644 index 000000000..d4a7c2c72 --- /dev/null +++ b/lib/vuid/vuid_manager_factory.browser.spec.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2024, 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 { vi, describe, expect, it, beforeEach } from 'vitest'; + +vi.mock('../utils/cache/local_storage_cache.browser', () => { + return { + LocalStorageCache: vi.fn(), + }; +}); + +vi.mock('./vuid_manager', () => { + return { + DefaultVuidManager: vi.fn(), + VuidCacheManager: vi.fn(), + }; +}); + +import { getMockSyncCache } from '../tests/mock/mock_cache'; +import { createVuidManager } from './vuid_manager_factory.browser'; +import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser'; +import { DefaultVuidManager, VuidCacheManager } from './vuid_manager'; + +describe('createVuidManager', () => { + const MockVuidCacheManager = vi.mocked(VuidCacheManager); + const MockLocalStorageCache = vi.mocked(LocalStorageCache); + const MockDefaultVuidManager = vi.mocked(DefaultVuidManager); + + beforeEach(() => { + MockLocalStorageCache.mockClear(); + MockDefaultVuidManager.mockClear(); + }); + + it('should pass the enableVuid option to the DefaultVuidManager', () => { + const manager = createVuidManager({ enableVuid: true }); + expect(manager).toBe(MockDefaultVuidManager.mock.instances[0]); + expect(MockDefaultVuidManager.mock.calls[0][0].enableVuid).toBe(true); + + const manager2 = createVuidManager({ enableVuid: false }); + expect(manager2).toBe(MockDefaultVuidManager.mock.instances[1]); + expect(MockDefaultVuidManager.mock.calls[1][0].enableVuid).toBe(false); + }); + + it('should use the provided cache', () => { + const cache = getMockSyncCache(); + const manager = createVuidManager({ enableVuid: true, vuidCache: cache }); + expect(manager).toBe(MockDefaultVuidManager.mock.instances[0]); + expect(MockDefaultVuidManager.mock.calls[0][0].vuidCache).toBe(cache); + }); + + it('should use a LocalStorageCache if no cache is provided', () => { + const manager = createVuidManager({ enableVuid: true }); + expect(manager).toBe(MockDefaultVuidManager.mock.instances[0]); + + const usedCache = MockDefaultVuidManager.mock.calls[0][0].vuidCache; + expect(usedCache).toBe(MockLocalStorageCache.mock.instances[0]); + }); + + it('should use a single VuidCacheManager instance for all VuidManager instances', () => { + const manager1 = createVuidManager({ enableVuid: true }); + const manager2 = createVuidManager({ enableVuid: true }); + expect(manager1).toBe(MockDefaultVuidManager.mock.instances[0]); + expect(manager2).toBe(MockDefaultVuidManager.mock.instances[1]); + expect(MockVuidCacheManager.mock.instances.length).toBe(1); + + const usedCacheManager1 = MockDefaultVuidManager.mock.calls[0][0].vuidCacheManager; + const usedCacheManager2 = MockDefaultVuidManager.mock.calls[1][0].vuidCacheManager; + expect(usedCacheManager1).toBe(usedCacheManager2); + expect(usedCacheManager1).toBe(MockVuidCacheManager.mock.instances[0]); + }); +}); diff --git a/lib/vuid/vuid_manager_factory.browser.ts b/lib/vuid/vuid_manager_factory.browser.ts new file mode 100644 index 000000000..cf8df6a44 --- /dev/null +++ b/lib/vuid/vuid_manager_factory.browser.ts @@ -0,0 +1,28 @@ +/** +* Copyright 2024, 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 { DefaultVuidManager, VuidCacheManager, VuidManager } from './vuid_manager'; +import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser'; +import { VuidManagerOptions } from './vuid_manager_factory'; + +export const vuidCacheManager = new VuidCacheManager(); + +export const createVuidManager = (options: VuidManagerOptions): VuidManager => { + return new DefaultVuidManager({ + vuidCacheManager, + vuidCache: options.vuidCache || new LocalStorageCache(), + enableVuid: options.enableVuid + }); +} diff --git a/lib/odp/odp_utils.ts b/lib/vuid/vuid_manager_factory.node.spec.ts similarity index 51% rename from lib/odp/odp_utils.ts rename to lib/vuid/vuid_manager_factory.node.spec.ts index 875b7e091..2a81f9a8a 100644 --- a/lib/odp/odp_utils.ts +++ b/lib/vuid/vuid_manager_factory.node.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2023, Optimizely + * Copyright 2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,19 +14,13 @@ * limitations under the License. */ -/** - * 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 - */ -export function invalidOdpDataFound(data: Map): boolean { - const validTypes: string[] = ['string', 'number', 'boolean']; - let foundInvalidValue = false; - data.forEach(value => { - if (!validTypes.includes(typeof value) && value !== null) { - foundInvalidValue = true; - } +import { vi, describe, expect, it } from 'vitest'; + +import { createVuidManager } from './vuid_manager_factory.node'; + +describe('createVuidManager', () => { + it('should throw an error', () => { + expect(() => createVuidManager({ enableVuid: true })) + .toThrowError('VUID is not supported in Node.js environment'); }); - return foundInvalidValue; -} +}); diff --git a/lib/vuid/vuid_manager_factory.node.ts b/lib/vuid/vuid_manager_factory.node.ts new file mode 100644 index 000000000..993fbb60a --- /dev/null +++ b/lib/vuid/vuid_manager_factory.node.ts @@ -0,0 +1,22 @@ +/** +* Copyright 2024, 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 { VuidManager } from './vuid_manager'; +import { VuidManagerOptions } from './vuid_manager_factory'; + +export const createVuidManager = (options: VuidManagerOptions): VuidManager => { + throw new Error('VUID is not supported in Node.js environment'); +}; + diff --git a/lib/vuid/vuid_manager_factory.react_native.spec.ts b/lib/vuid/vuid_manager_factory.react_native.spec.ts new file mode 100644 index 000000000..22920c099 --- /dev/null +++ b/lib/vuid/vuid_manager_factory.react_native.spec.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2024, 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 { vi, describe, expect, it, beforeEach } from 'vitest'; + +vi.mock('../utils/cache/async_storage_cache.react_native', () => { + return { + AsyncStorageCache: vi.fn(), + }; +}); + +vi.mock('./vuid_manager', () => { + return { + DefaultVuidManager: vi.fn(), + VuidCacheManager: vi.fn(), + }; +}); + +import { getMockAsyncCache } from '../tests/mock/mock_cache'; +import { createVuidManager } from './vuid_manager_factory.react_native'; +import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native'; + +import { DefaultVuidManager, VuidCacheManager } from './vuid_manager'; + +describe('createVuidManager', () => { + const MockVuidCacheManager = vi.mocked(VuidCacheManager); + const MockAsyncStorageCache = vi.mocked(AsyncStorageCache); + const MockDefaultVuidManager = vi.mocked(DefaultVuidManager); + + beforeEach(() => { + MockAsyncStorageCache.mockClear(); + MockDefaultVuidManager.mockClear(); + }); + + it('should pass the enableVuid option to the DefaultVuidManager', () => { + const manager = createVuidManager({ enableVuid: true }); + expect(manager).toBe(MockDefaultVuidManager.mock.instances[0]); + expect(MockDefaultVuidManager.mock.calls[0][0].enableVuid).toBe(true); + + const manager2 = createVuidManager({ enableVuid: false }); + expect(manager2).toBe(MockDefaultVuidManager.mock.instances[1]); + expect(MockDefaultVuidManager.mock.calls[1][0].enableVuid).toBe(false); + }); + + it('should use the provided cache', () => { + const cache = getMockAsyncCache(); + const manager = createVuidManager({ enableVuid: true, vuidCache: cache }); + expect(manager).toBe(MockDefaultVuidManager.mock.instances[0]); + expect(MockDefaultVuidManager.mock.calls[0][0].vuidCache).toBe(cache); + }); + + it('should use a AsyncStorageCache if no cache is provided', () => { + const manager = createVuidManager({ enableVuid: true }); + expect(manager).toBe(MockDefaultVuidManager.mock.instances[0]); + + const usedCache = MockDefaultVuidManager.mock.calls[0][0].vuidCache; + expect(usedCache).toBe(MockAsyncStorageCache.mock.instances[0]); + }); + + it('should use a single VuidCacheManager instance for all VuidManager instances', () => { + const manager1 = createVuidManager({ enableVuid: true }); + const manager2 = createVuidManager({ enableVuid: true }); + expect(manager1).toBe(MockDefaultVuidManager.mock.instances[0]); + expect(manager2).toBe(MockDefaultVuidManager.mock.instances[1]); + expect(MockVuidCacheManager.mock.instances.length).toBe(1); + + const usedCacheManager1 = MockDefaultVuidManager.mock.calls[0][0].vuidCacheManager; + const usedCacheManager2 = MockDefaultVuidManager.mock.calls[1][0].vuidCacheManager; + expect(usedCacheManager1).toBe(usedCacheManager2); + expect(usedCacheManager1).toBe(MockVuidCacheManager.mock.instances[0]); + }); +}); diff --git a/lib/vuid/vuid_manager_factory.react_native.ts b/lib/vuid/vuid_manager_factory.react_native.ts new file mode 100644 index 000000000..6eba4c9f2 --- /dev/null +++ b/lib/vuid/vuid_manager_factory.react_native.ts @@ -0,0 +1,28 @@ +/** +* Copyright 2024, 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 { DefaultVuidManager, VuidCacheManager, VuidManager } from './vuid_manager'; +import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native'; +import { VuidManagerOptions } from './vuid_manager_factory'; + +export const vuidCacheManager = new VuidCacheManager(); + +export const createVuidManager = (options: VuidManagerOptions): VuidManager => { + return new DefaultVuidManager({ + vuidCacheManager, + vuidCache: options.vuidCache || new AsyncStorageCache(), + enableVuid: options.enableVuid + }); +} diff --git a/lib/utils/lru_cache/index.ts b/lib/vuid/vuid_manager_factory.ts similarity index 63% rename from lib/utils/lru_cache/index.ts rename to lib/vuid/vuid_manager_factory.ts index fb7ada423..ab2264242 100644 --- a/lib/utils/lru_cache/index.ts +++ b/lib/vuid/vuid_manager_factory.ts @@ -1,11 +1,11 @@ /** - * Copyright 2022, Optimizely + * Copyright 2024, 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 + * 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, @@ -14,8 +14,9 @@ * limitations under the License. */ -import { ICache, LRUCache } from './lru_cache'; -import { BrowserLRUCache } from './browser_lru_cache'; -import { ServerLRUCache } from './server_lru_cache'; +import { Cache } from '../utils/cache/cache'; -export { ICache, LRUCache, BrowserLRUCache, ServerLRUCache }; +export type VuidManagerOptions = { + vuidCache?: Cache; + enableVuid?: boolean; +} diff --git a/tests/browserAsyncStorageCache.spec.ts b/tests/browserAsyncStorageCache.spec.ts deleted file mode 100644 index c30b675bc..000000000 --- a/tests/browserAsyncStorageCache.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright 2022, 2024, 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 { describe, beforeEach, it, expect, vi } from 'vitest'; - -import BrowserAsyncStorageCache from '../lib/plugins/key_value_cache/browserAsyncStorageCache'; - -describe('BrowserAsyncStorageCache', () => { - const KEY_THAT_EXISTS = 'keyThatExists'; - const VALUE_FOR_KEY_THAT_EXISTS = 'some really super value that exists for keyThatExists'; - const NONEXISTENT_KEY = 'someKeyThatDoesNotExist'; - - let cacheInstance: BrowserAsyncStorageCache; - - beforeEach(() => { - const stubData = new Map(); - stubData.set(KEY_THAT_EXISTS, VALUE_FOR_KEY_THAT_EXISTS); - - cacheInstance = new BrowserAsyncStorageCache(); - - vi - .spyOn(localStorage, 'getItem') - .mockImplementation((key) => key == KEY_THAT_EXISTS ? VALUE_FOR_KEY_THAT_EXISTS : null); - vi - .spyOn(localStorage, 'setItem') - .mockImplementation(() => 1); - vi - .spyOn(localStorage, 'removeItem') - .mockImplementation((key) => key == KEY_THAT_EXISTS); - }); - - describe('contains', () => { - it('should return true if value with key exists', async () => { - const keyWasFound = await cacheInstance.contains(KEY_THAT_EXISTS); - - expect(keyWasFound).toBe(true); - }); - - it('should return false if value with key does not exist', async () => { - const keyWasFound = await cacheInstance.contains(NONEXISTENT_KEY); - - expect(keyWasFound).toBe(false); - }); - }); - - describe('get', () => { - it('should return correct string when item is found in cache', async () => { - const foundValue = await cacheInstance.get(KEY_THAT_EXISTS); - - expect(foundValue).toEqual(VALUE_FOR_KEY_THAT_EXISTS); - }); - - it('should return undefined if item is not found in cache', async () => { - const json = await cacheInstance.get(NONEXISTENT_KEY); - - expect(json).toBeUndefined(); - }); - }); - - describe('remove', () => { - it('should return true after removing a found entry', async () => { - const wasSuccessful = await cacheInstance.remove(KEY_THAT_EXISTS); - - expect(wasSuccessful).toBe(true); - }); - - it('should return false after trying to remove an entry that is not found ', async () => { - const wasSuccessful = await cacheInstance.remove(NONEXISTENT_KEY); - - expect(wasSuccessful).toBe(false); - }); - }); - - describe('set', () => { - it('should resolve promise if item was successfully set in the cache', async () => { - await cacheInstance.set('newTestKey', 'a value for this newTestKey'); - }); - }); -}); diff --git a/tests/odpEventApiManager.spec.ts b/tests/odpEventApiManager.spec.ts deleted file mode 100644 index 07632c72a..000000000 --- a/tests/odpEventApiManager.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Copyright 2022-2024, 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 { describe, beforeEach, beforeAll, it, expect } from 'vitest'; - -import { anyString, anything, capture, instance, mock, resetCalls, verify, when } from 'ts-mockito'; -import { LogHandler, LogLevel } from '../lib/modules/logging'; -import { NodeOdpEventApiManager } from '../lib/odp/event_manager/event_api_manager.node'; -import { OdpEvent } from '../lib/odp/event_manager/odp_event'; -import { RequestHandler } from '../lib/utils/http_request_handler/http'; -import { OdpConfig } from '../lib/odp/odp_config'; - -const data1 = new Map(); -data1.set('key11', 'value-1'); -data1.set('key12', true); -data1.set('key12', 3.5); -data1.set('key14', null); -const data2 = new Map(); -data2.set('key2', 'value-2'); -const ODP_EVENTS = [ - new OdpEvent('t1', 'a1', new Map([['id-key-1', 'id-value-1']]), data1), - new OdpEvent('t2', 'a2', new Map([['id-key-2', 'id-value-2']]), data2), -]; - -const API_KEY = 'test-api-key'; -const API_HOST = 'https://odp.example.com'; -const PIXEL_URL = 'https://odp.pixel.com'; - -const odpConfig = new OdpConfig(API_KEY, API_HOST, PIXEL_URL, []); - -describe('NodeOdpEventApiManager', () => { - let mockLogger: LogHandler; - let mockRequestHandler: RequestHandler; - - beforeAll(() => { - mockLogger = mock(); - mockRequestHandler = mock(); - }); - - beforeEach(() => { - resetCalls(mockLogger); - resetCalls(mockRequestHandler); - }); - - const managerInstance = () => { - const manager = new NodeOdpEventApiManager(instance(mockRequestHandler), instance(mockLogger)); - return manager; - } - - const abortableRequest = (statusCode: number, body: string) => { - return { - abort: () => {}, - responsePromise: Promise.resolve({ - statusCode, - body, - headers: {}, - }), - }; - }; - - it('should should send events successfully and not suggest retry', async () => { - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, '') - ); - const manager = managerInstance(); - - const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); - - expect(shouldRetry).toBe(false); - verify(mockLogger.log(anything(), anyString())).never(); - }); - - it('should not suggest a retry for 400 HTTP response', async () => { - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(400, '') - ); - const manager = managerInstance(); - - const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); - - expect(shouldRetry).toBe(false); - verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (400)')).once(); - }); - - it('should suggest a retry for 500 HTTP response', async () => { - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(500, '') - ); - const manager = managerInstance(); - - const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); - - expect(shouldRetry).toBe(true); - verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (500)')).once(); - }); - - it('should suggest a retry for network timeout', async () => { - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => {}, - responsePromise: Promise.reject(new Error('Request timed out')), - }); - const manager = managerInstance(); - - const shouldRetry = await manager.sendEvents(odpConfig, ODP_EVENTS); - - expect(shouldRetry).toBe(true); - verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (Request timed out)')).once(); - }); - - it('should send events to the correct host using correct api key', async () => { - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => {}, - responsePromise: Promise.reject(new Error('Request timed out')), - }); - - const manager = managerInstance(); - - await manager.sendEvents(odpConfig, ODP_EVENTS); - - verify(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).once(); - - const [initUrl, headers] = capture(mockRequestHandler.makeRequest).first(); - expect(initUrl).toEqual(`${API_HOST}/v3/events`); - expect(headers['x-api-key']).toEqual(odpConfig.apiKey); - }); -}); diff --git a/tests/odpEventManager.spec.ts b/tests/odpEventManager.spec.ts deleted file mode 100644 index 38cf9d379..000000000 --- a/tests/odpEventManager.spec.ts +++ /dev/null @@ -1,733 +0,0 @@ -/** - * Copyright 2022-2024, 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 { describe, beforeEach, afterEach, beforeAll, it, vi, expect } from 'vitest'; - -import { ODP_EVENT_ACTION, ODP_DEFAULT_EVENT_TYPE, ERROR_MESSAGES } from '../lib/utils/enums'; -import { OdpConfig } from '../lib/odp/odp_config'; -import { Status } from '../lib/odp/event_manager/odp_event_manager'; -import { BrowserOdpEventManager } from '../lib/odp/event_manager/event_manager.browser'; -import { NodeOdpEventManager } from '../lib/odp/event_manager/event_manager.node'; -import { OdpEventManager } from '../lib/odp/event_manager/odp_event_manager'; -import { anything, capture, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; -import { IOdpEventApiManager } from '../lib/odp/event_manager/odp_event_api_manager'; -import { LogHandler, LogLevel } from '../lib/modules/logging'; -import { OdpEvent } from '../lib/odp/event_manager/odp_event'; -import { IUserAgentParser } from '../lib/odp/ua_parser/user_agent_parser'; -import { UserAgentInfo } from '../lib/odp/ua_parser/user_agent_info'; -import { resolve } from 'path'; -import { advanceTimersByTime } from './testUtils'; - -const API_KEY = 'test-api-key'; -const API_HOST = 'https://odp.example.com'; -const PIXEL_URL = 'https://odp.pixel.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([ - ['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.3'; -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 EVENT_WITH_EMPTY_IDENTIFIER = new OdpEvent( - 't4', - 'a4', - new Map(), - new Map([ - ['key-53f3', true], - ['key-a04a', 123], - ['key-2ab4', 'Linus Torvalds'], - ]), -); -const EVENT_WITH_UNDEFINED_IDENTIFIER = new OdpEvent( - 't4', - 'a4', - undefined, - new Map([ - ['key-53f3', false], - ['key-a04a', 456], - ['key-2ab4', 'Bill Gates'] - ]), -); -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: {}, - }), - }; -}; - -class TestOdpEventManager extends OdpEventManager { - constructor(options: any) { - super(options); - } - protected initParams(batchSize: number, queueSize: number, flushInterval: number): void { - this.queueSize = queueSize; - this.batchSize = batchSize; - this.flushInterval = flushInterval; - } - protected discardEventsIfNeeded(): void { - } - protected hasNecessaryIdentifiers = (event: OdpEvent): boolean => event.identifiers.size >= 0; -} - -describe('OdpEventManager', () => { - let mockLogger: LogHandler; - let mockApiManager: IOdpEventApiManager; - - let odpConfig: OdpConfig; - let logger: LogHandler; - let apiManager: IOdpEventApiManager; - - beforeAll(() => { - mockLogger = mock(); - mockApiManager = mock(); - odpConfig = new OdpConfig(API_KEY, API_HOST, PIXEL_URL, []); - logger = instance(mockLogger); - apiManager = instance(mockApiManager); - }); - - beforeEach(() => { - vi.useFakeTimers(); - resetCalls(mockLogger); - resetCalls(mockApiManager); - }); - - afterEach(() => { - vi.clearAllTimers(); - }); - - it('should log an error and not start if start() is called without a config', () => { - const eventManager = new TestOdpEventManager({ - odpConfig: undefined, - apiManager, - logger, - clientEngine, - clientVersion, - }); - - eventManager.start(); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).once(); - expect(eventManager.status).toEqual(Status.Stopped); - }); - - it('should start() correctly after odpConfig is provided', () => { - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); - - expect(eventManager.status).toEqual(Status.Stopped); - eventManager.updateSettings(odpConfig); - eventManager.start(); - expect(eventManager.status).toEqual(Status.Running); - }); - - it('should log and discard events when event manager is not running', () => { - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); - - expect(eventManager.status).toEqual(Status.Stopped); - eventManager.sendEvent(EVENTS[0]); - verify(mockLogger.log(LogLevel.WARNING, 'Failed to Process ODP Event. ODPEventManager is not running.')).once(); - expect(eventManager.getQueue().length).toEqual(0); - }); - - it('should discard events with invalid data', () => { - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); - eventManager.start(); - - expect(eventManager.status).toEqual(Status.Running); - - // 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([ - ['key-1', false], - ['key-2', { random: 'object', whichShouldFail: true }], - ]), - ); - eventManager.sendEvent(badEvent); - - verify(mockLogger.log(LogLevel.ERROR, 'Event data found to be invalid.')).once(); - expect(eventManager.getQueue().length).toEqual(0); - }); - - it('should log a max queue hit and discard ', () => { - // set queue to maximum of 1 - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - queueSize: 1, // With max queue size set to 1... - }); - - eventManager.start(); - - 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 TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); - eventManager.start(); - - 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 if batchSize is greater than 1', async () => { - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 10, - flushInterval: 100, - }); - - //@ts-ignore - const processQueueSpy = vi.spyOn(eventManager, 'processQueue'); - - eventManager.start(); - // do not add events to the queue, but allow for... - vi.advanceTimersByTime(350); // 3 flush intervals executions (giving a little longer) - - expect(processQueueSpy).toHaveBeenCalledTimes(3); - }); - - - it('should not flush periodically if batch size is 1', async () => { - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 1, - flushInterval: 100, - }); - - //@ts-ignore - const processQueueSpy = vi.spyOn(eventManager, 'processQueue'); - - eventManager.start(); - eventManager.sendEvent(EVENTS[0]); - eventManager.sendEvent(EVENTS[1]); - - vi.advanceTimersByTime(350); // 3 flush intervals executions (giving a little longer) - - expect(processQueueSpy).toHaveBeenCalledTimes(2); - }); - - it('should dispatch events in correct batch sizes', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); - - const apiManager = instance(mockApiManager); - const eventManager = new TestOdpEventManager({ - 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 Promise.resolve(); - - // as we are not advancing the vi fake timers, no flush should occur - // ...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())).twice(); - - // rest of the events should now be flushed - await advanceTimersByTime(250); - verify(mockApiManager.sendEvents(anything(), anything())).thrice(); - }); - - it('should dispatch events with correct payload', async () => { - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 10, - flushInterval: 100, - }); - - eventManager.start(); - EVENTS.forEach(event => eventManager.sendEvent(event)); - - await advanceTimersByTime(100); - // sending 1 batch of 2 events after flushInterval since batchSize is 10 - verify(mockApiManager.sendEvents(anything(), anything())).once(); - const [_, events] = capture(mockApiManager.sendEvents).last(); - 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 dispatch events with correct odpConfig', async () => { - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 10, - flushInterval: 100, - }); - - eventManager.start(); - EVENTS.forEach(event => eventManager.sendEvent(event)); - - await advanceTimersByTime(100); - - // sending 1 batch of 2 events after flushInterval since batchSize is 10 - verify(mockApiManager.sendEvents(anything(), anything())).once(); - const [usedOdpConfig] = capture(mockApiManager.sendEvents).last(); - expect(usedOdpConfig.equals(odpConfig)).toBeTruthy(); - }); - - it('should augment events with data from user agent parser', async () => { - const userAgentParser : IUserAgentParser = { - parseUserAgentInfo: function (): UserAgentInfo { - return { - os: { 'name': 'windows', 'version': '11' }, - device: { 'type': 'laptop', 'model': 'thinkpad' }, - } - } - } - - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 10, - flushInterval: 100, - userAgentParser, - }); - - eventManager.start(); - EVENTS.forEach(event => eventManager.sendEvent(event)); - await advanceTimersByTime(100); - - verify(mockApiManager.sendEvents(anything(), anything())).called(); - const [_, events] = capture(mockApiManager.sendEvents).last(); - const event = events[0]; - - expect(event.data.get('os')).toEqual('windows'); - expect(event.data.get('os_version')).toEqual('11'); - expect(event.data.get('device_type')).toEqual('laptop'); - expect(event.data.get('model')).toEqual('thinkpad'); - }); - - it('should retry failed events', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(true) - - const retries = 3; - const apiManager = instance(mockApiManager); - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 2, - flushInterval: 100, - retries, - }); - - eventManager.start(); - for (let i = 0; i < 4; i += 1) { - eventManager.sendEvent(makeEvent(i)); - } - - vi.runAllTicks(); - vi.useRealTimers(); - await pause(100); - - // retry 3x for 2 batches or 6 calls to attempt to process - verify(mockApiManager.sendEvents(anything(), anything())).times(6); - }); - - it('should flush all queued events when flush() is called', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); - - const apiManager = instance(mockApiManager); - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 200, - flushInterval: 100, - }); - - eventManager.start(); - for (let i = 0; i < 25; i += 1) { - eventManager.sendEvent(makeEvent(i)); - } - - expect(eventManager.getQueue().length).toEqual(25); - - eventManager.flush(); - - await Promise.resolve(); - - verify(mockApiManager.sendEvents(anything(), anything())).once(); - expect(eventManager.getQueue().length).toEqual(0); - }); - - it('should flush all queued events before stopping', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); - const apiManager = instance(mockApiManager); - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 200, - flushInterval: 100, - }); - - eventManager.start(); - for (let i = 0; i < 25; i += 1) { - eventManager.sendEvent(makeEvent(i)); - } - - expect(eventManager.getQueue().length).toEqual(25); - - eventManager.flush(); - - await Promise.resolve(); - - verify(mockApiManager.sendEvents(anything(), anything())).once(); - expect(eventManager.getQueue().length).toEqual(0); - }); - - it('should flush all queued events using the old odpConfig when updateSettings is called()', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); - - const odpConfig = new OdpConfig('old-key', 'old-host', 'https://new-odp.pixel.com', []); - const updatedConfig = new OdpConfig('new-key', 'new-host', 'https://new-odp.pixel.com', []); - - const apiManager = instance(mockApiManager); - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 200, - flushInterval: 100, - }); - - eventManager.start(); - for (let i = 0; i < 25; i += 1) { - eventManager.sendEvent(makeEvent(i)); - } - - expect(eventManager.getQueue().length).toEqual(25); - - eventManager.updateSettings(updatedConfig); - - await Promise.resolve(); - - verify(mockApiManager.sendEvents(anything(), anything())).once(); - expect(eventManager.getQueue().length).toEqual(0); - const [usedOdpConfig] = capture(mockApiManager.sendEvents).last(); - expect(usedOdpConfig.equals(odpConfig)).toBeTruthy(); - }); - - it('should use updated odpConfig to send events', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); - - const odpConfig = new OdpConfig('old-key', 'old-host', 'https://new-odp.pixel.com', []); - const updatedConfig = new OdpConfig('new-key', 'new-host', 'https://new-odp.pixel.com', []); - - const apiManager = instance(mockApiManager); - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 200, - flushInterval: 100, - }); - - eventManager.start(); - for (let i = 0; i < 25; i += 1) { - eventManager.sendEvent(makeEvent(i)); - } - - expect(eventManager.getQueue().length).toEqual(25); - - await advanceTimersByTime(100); - - expect(eventManager.getQueue().length).toEqual(0); - let [usedOdpConfig] = capture(mockApiManager.sendEvents).first(); - expect(usedOdpConfig.equals(odpConfig)).toBeTruthy(); - - eventManager.updateSettings(updatedConfig); - - for (let i = 0; i < 25; i += 1) { - eventManager.sendEvent(makeEvent(i)); - } - - await advanceTimersByTime(100); - - expect(eventManager.getQueue().length).toEqual(0); - ([usedOdpConfig] = capture(mockApiManager.sendEvents).last()); - expect(usedOdpConfig.equals(updatedConfig)).toBeTruthy(); - }); - - it('should prepare correct payload for register VUID', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); - - const apiManager = instance(mockApiManager); - - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 10, - flushInterval: 250, - }); - - const vuid = 'vuid_330e05cad15746d9af8a75b8d10'; - const fsUserId = 'test-fs-user-id'; - - eventManager.start(); - eventManager.registerVuid(vuid); - - await advanceTimersByTime(250); - - const [_, events] = capture(mockApiManager.sendEvents).last(); - expect(events.length).toBe(1); - - const [event] = events; - expect(event.type).toEqual('fullstack'); - expect(event.action).toEqual(ODP_EVENT_ACTION.INITIALIZED); - expect(event.identifiers).toEqual(new Map([['vuid', vuid]])); - expect((event.data.get("idempotence_id") as string).length).toBe(36); // uuid length - expect((event.data.get("data_source_type") as string)).toEqual('sdk'); - expect((event.data.get("data_source") as string)).toEqual('javascript-sdk'); - expect(event.data.get("data_source_version") as string).not.toBeNull(); - }); - - it('should send correct event payload for identify user', async () => { - when(mockApiManager.sendEvents(anything(), anything())).thenResolve(false); - - const apiManager = instance(mockApiManager); - - const eventManager = new TestOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - batchSize: 10, - flushInterval: 250, - }); - - const vuid = 'vuid_330e05cad15746d9af8a75b8d10'; - const fsUserId = 'test-fs-user-id'; - - eventManager.start(); - eventManager.identifyUser(fsUserId, vuid); - - await advanceTimersByTime(260); - - const [_, events] = capture(mockApiManager.sendEvents).last(); - expect(events.length).toBe(1); - - const [event] = events; - expect(event.type).toEqual(ODP_DEFAULT_EVENT_TYPE); - expect(event.action).toEqual(ODP_EVENT_ACTION.IDENTIFIED); - expect(event.identifiers).toEqual(new Map([['vuid', vuid], ['fs_user_id', fsUserId]])); - expect((event.data.get("idempotence_id") as string).length).toBe(36); // uuid length - expect((event.data.get("data_source_type") as string)).toEqual('sdk'); - expect((event.data.get("data_source") as string)).toEqual('javascript-sdk'); - expect(event.data.get("data_source_version") as string).not.toBeNull(); - }); - - it('should error when no identifiers are provided in Node', () => { - const eventManager = new NodeOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); - - eventManager.start(); - eventManager.sendEvent(EVENT_WITH_EMPTY_IDENTIFIER); - eventManager.sendEvent(EVENT_WITH_UNDEFINED_IDENTIFIER); - eventManager.stop(); - - vi.runAllTicks(); - - verify(mockLogger.log(LogLevel.ERROR, 'ODP events should have at least one key-value pair in identifiers.')).twice(); - }); - - it('should never error when no identifiers are provided in Browser', () => { - const eventManager = new BrowserOdpEventManager({ - odpConfig, - apiManager, - logger, - clientEngine, - clientVersion, - }); - - eventManager.start(); - eventManager.sendEvent(EVENT_WITH_EMPTY_IDENTIFIER); - eventManager.sendEvent(EVENT_WITH_UNDEFINED_IDENTIFIER); - eventManager.stop(); - - vi.runAllTicks(); - - verify(mockLogger.log(LogLevel.ERROR, 'ODP events should have at least one key-value pair in identifiers.')).never(); - }); -}); diff --git a/tests/odpManager.browser.spec.ts b/tests/odpManager.browser.spec.ts deleted file mode 100644 index ee9415a78..000000000 --- a/tests/odpManager.browser.spec.ts +++ /dev/null @@ -1,513 +0,0 @@ -/** - * Copyright 2023-2024, 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 { describe, beforeEach, beforeAll, it, expect } from 'vitest'; - -import { instance, mock, resetCalls } from 'ts-mockito'; - -import { LogHandler, LogLevel } from '../lib/modules/logging'; -import { RequestHandler } from '../lib/utils/http_request_handler/http'; -import { BrowserLRUCache } from './../lib/utils/lru_cache/browser_lru_cache'; - -import { BrowserOdpManager } from './../lib/odp/odp_manager.browser'; - -import { OdpConfig } from '../lib/odp/odp_config'; -import { BrowserOdpEventApiManager } from '../lib/odp/event_manager/event_api_manager.browser'; -import { OdpSegmentManager } from './../lib/odp/segment_manager/odp_segment_manager'; -import { OdpSegmentApiManager } from '../lib/odp/segment_manager/odp_segment_api_manager'; -import { VuidManager } from '../lib/plugins/vuid_manager'; -import { BrowserRequestHandler } from '../lib/utils/http_request_handler/browser_request_handler'; -import { BrowserOdpEventManager } from '../lib/odp/event_manager/event_manager.browser'; -import { OdpOptions } from '../lib/shared_types'; - - -const keyA = 'key-a'; -const hostA = 'host-a'; -const pixelA = 'pixel-a'; -const segmentsA = ['a']; -const userA = 'fs-user-a'; -const vuidA = 'vuid_a'; -const odpConfigA = new OdpConfig(keyA, hostA, pixelA, segmentsA); - -const keyB = 'key-b'; -const hostB = 'host-b'; -const pixelB = 'pixel-b'; -const segmentsB = ['b']; -const userB = 'fs-user-b'; -const vuidB = 'vuid_b'; -const odpConfigB = new OdpConfig(keyB, hostB, pixelB, segmentsB); - -describe('OdpManager', () => { - let odpConfig: OdpConfig; - - let mockLogger: LogHandler; - let fakeLogger: LogHandler; - - let mockRequestHandler: RequestHandler; - let fakeRequestHandler: RequestHandler; - - let mockEventApiManager: BrowserOdpEventApiManager; - let fakeEventApiManager: BrowserOdpEventApiManager; - - let mockEventManager: BrowserOdpEventManager; - let fakeEventManager: BrowserOdpEventManager; - - let mockSegmentApiManager: OdpSegmentApiManager; - let fakeSegmentApiManager: OdpSegmentApiManager; - - let mockSegmentManager: OdpSegmentManager; - let fakeSegmentManager: OdpSegmentManager; - - let mockBrowserOdpManager: BrowserOdpManager; - let fakeBrowserOdpManager: BrowserOdpManager; - - beforeAll(() => { - mockLogger = mock(); - mockRequestHandler = mock(); - - odpConfig = new OdpConfig(keyA, hostA, pixelA, segmentsA); - fakeLogger = instance(mockLogger); - fakeRequestHandler = instance(mockRequestHandler); - - mockEventApiManager = mock(); - mockEventManager = mock(); - mockSegmentApiManager = mock(); - mockSegmentManager = mock(); - mockBrowserOdpManager = mock(); - - fakeEventApiManager = instance(mockEventApiManager); - fakeEventManager = instance(mockEventManager); - fakeSegmentApiManager = instance(mockSegmentApiManager); - fakeSegmentManager = instance(mockSegmentManager); - fakeBrowserOdpManager = instance(mockBrowserOdpManager); - }); - - beforeEach(() => { - resetCalls(mockLogger); - resetCalls(mockRequestHandler); - resetCalls(mockEventApiManager); - resetCalls(mockEventManager); - resetCalls(mockSegmentManager); - }); - - const browserOdpManagerInstance = () => - BrowserOdpManager.createInstance({ - odpOptions: { - eventManager: fakeEventManager, - segmentManager: fakeSegmentManager, - }, - }); - - it('should create VUID automatically on BrowserOdpManager initialization', async () => { - const browserOdpManager = browserOdpManagerInstance(); - const vuidManager = await VuidManager.instance(BrowserOdpManager.cache); - expect(browserOdpManager.vuid).toBe(vuidManager.vuid); - }); - - describe('Populates BrowserOdpManager correctly with all odpOptions', () => { - beforeAll(() => { - - }); - - it('Custom odpOptions.segmentsCache overrides default LRUCache', () => { - const odpOptions: OdpOptions = { - segmentsCache: new BrowserLRUCache({ - maxSize: 2, - timeout: 4000, - }), - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - const segmentManager = browserOdpManager['segmentManager'] as OdpSegmentManager; - - // @ts-ignore - expect(browserOdpManager.segmentManager._segmentsCache.maxSize).toBe(2); - - // @ts-ignore - expect(browserOdpManager.segmentManager._segmentsCache.timeout).toBe(4000); - }); - - it('Custom odpOptions.segmentsCacheSize overrides default LRUCache size', () => { - const odpOptions: OdpOptions = { - segmentsCacheSize: 2, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager._segmentsCache.maxSize).toBe(2); - }); - - it('Custom odpOptions.segmentsCacheTimeout overrides default LRUCache timeout', () => { - const odpOptions: OdpOptions = { - segmentsCacheTimeout: 4000, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager._segmentsCache.timeout).toBe(4000); - }); - - it('Custom odpOptions.segmentsApiTimeout overrides default Segment API Request Handler timeout', () => { - const odpOptions: OdpOptions = { - segmentsApiTimeout: 4000, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager.odpSegmentApiManager.requestHandler.timeout).toBe(4000); - }); - - it('Browser default Segments API Request Handler timeout should be used when odpOptions does not include segmentsApiTimeout', () => { - const browserOdpManager = BrowserOdpManager.createInstance({}); - - // @ts-ignore - expect(browserOdpManager.segmentManager.odpSegmentApiManager.requestHandler.timeout).toBe(10000); - }); - - it('Custom odpOptions.segmentsRequestHandler overrides default Segment API Request Handler', () => { - const odpOptions: OdpOptions = { - segmentsRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 4000 }), - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager.odpSegmentApiManager.requestHandler.timeout).toBe(4000); - }); - - it('Custom odpOptions.segmentRequestHandler override takes precedence over odpOptions.eventApiTimeout', () => { - const odpOptions: OdpOptions = { - segmentsApiTimeout: 2, - segmentsRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager.odpSegmentApiManager.requestHandler.timeout).toBe(1); - }); - - it('Custom odpOptions.segmentManager overrides default Segment Manager', () => { - const customSegmentManager = new OdpSegmentManager( - new BrowserLRUCache(), - fakeSegmentApiManager, - fakeLogger, - odpConfig, - ); - - const odpOptions: OdpOptions = { - segmentManager: customSegmentManager, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager).toBe(customSegmentManager); - }); - - it('Custom odpOptions.segmentManager override takes precedence over all other segments-related odpOptions', () => { - const customSegmentManager = new OdpSegmentManager( - new BrowserLRUCache({ - maxSize: 1, - timeout: 1, - }), - new OdpSegmentApiManager(new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), fakeLogger), - fakeLogger, - odpConfig, - ); - - const odpOptions: OdpOptions = { - segmentsCacheSize: 2, - segmentsCacheTimeout: 2, - segmentsCache: new BrowserLRUCache({ maxSize: 2, timeout: 2 }), - segmentsApiTimeout: 2, - segmentsRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 2 }), - segmentManager: customSegmentManager, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.maxSize).toBe(1); - - // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.timeout).toBe(1); - - // @ts-ignore - expect(browserOdpManager.segmentManager.odpSegmentApiManager.requestHandler.timeout).toBe(1); - - // @ts-ignore - expect(browserOdpManager.segmentManager).toBe(customSegmentManager); - }); - - it('Custom odpOptions.eventApiTimeout overrides default Event API Request Handler timeout', () => { - const odpOptions: OdpOptions = { - eventApiTimeout: 4000, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.apiManager.requestHandler.timeout).toBe(4000); - }); - - it('Browser default Events API Request Handler timeout should be used when odpOptions does not include eventsApiTimeout', () => { - const odpOptions: OdpOptions = {}; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.apiManager.requestHandler.timeout).toBe(10000); - }); - - it('Custom odpOptions.eventFlushInterval cannot override the default Event Manager flush interval', () => { - const odpOptions: OdpOptions = { - eventFlushInterval: 4000, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.flushInterval).toBe(0); // Note: Browser flush interval is always 0 due to use of Pixel API - }); - - it('Default ODP event flush interval is used when odpOptions does not include eventFlushInterval', () => { - const odpOptions: OdpOptions = {}; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.flushInterval).toBe(0); - }); - - it('ODP event batch size set to one when odpOptions.eventFlushInterval set to 0', () => { - const odpOptions: OdpOptions = { - eventFlushInterval: 0, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.flushInterval).toBe(0); - - // @ts-ignore - expect(browserOdpManager.eventManager.batchSize).toBe(1); - }); - - it('Custom odpOptions.eventBatchSize does not override default Event Manager batch size', () => { - const odpOptions: OdpOptions = { - eventBatchSize: 2, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.batchSize).toBe(1); // Note: Browser event batch size is always 1 due to use of Pixel API - }); - - it('Custom odpOptions.eventQueueSize overrides default Event Manager queue size', () => { - const odpOptions: OdpOptions = { - eventQueueSize: 2, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.queueSize).toBe(2); - }); - - it('Custom odpOptions.eventRequestHandler overrides default Event Manager request handler', () => { - const odpOptions: OdpOptions = { - eventRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 4000 }), - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.apiManager.requestHandler.timeout).toBe(4000); - }); - - it('Custom odpOptions.eventRequestHandler override takes precedence over odpOptions.eventApiTimeout', () => { - const odpOptions: OdpOptions = { - eventApiTimeout: 2, - eventBatchSize: 2, - eventFlushInterval: 2, - eventQueueSize: 2, - eventRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager.apiManager.requestHandler.timeout).toBe(1); - }); - - it('Custom odpOptions.eventManager overrides default Event Manager', () => { - const fakeClientEngine = 'test-javascript-sdk'; - const fakeClientVersion = '1.2.3'; - - const customEventManager = new BrowserOdpEventManager({ - odpConfig, - apiManager: fakeEventApiManager, - logger: fakeLogger, - clientEngine: fakeClientEngine, - clientVersion: fakeClientVersion, - }); - - const odpOptions: OdpOptions = { - eventManager: customEventManager, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager).toBe(customEventManager); - - // @ts-ignore - expect(browserOdpManager.eventManager.clientEngine).toBe(fakeClientEngine); - - // @ts-ignore - expect(browserOdpManager.eventManager.clientVersion).toBe(fakeClientVersion); - }); - - it('Custom odpOptions.eventManager override takes precedence over all other event-related odpOptions', () => { - const fakeClientEngine = 'test-javascript-sdk'; - const fakeClientVersion = '1.2.3'; - - const customEventManager = new BrowserOdpEventManager({ - odpConfig, - apiManager: new BrowserOdpEventApiManager(new BrowserRequestHandler({ logger: fakeLogger, timeout: 1 }), fakeLogger), - logger: fakeLogger, - clientEngine: fakeClientEngine, - clientVersion: fakeClientVersion, - queueSize: 1, - batchSize: 1, - flushInterval: 1, - }); - - const odpOptions: OdpOptions = { - eventApiTimeout: 2, - eventBatchSize: 2, - eventFlushInterval: 2, - eventQueueSize: 2, - eventRequestHandler: new BrowserRequestHandler({ logger: fakeLogger, timeout: 3 }), - eventManager: customEventManager, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.eventManager).toBe(customEventManager); - - // @ts-ignore - expect(browserOdpManager.eventManager.clientEngine).toBe(fakeClientEngine); - - // @ts-ignore - expect(browserOdpManager.eventManager.clientVersion).toBe(fakeClientVersion); - - // @ts-ignore - expect(browserOdpManager.eventManager.batchSize).toBe(1); - - // @ts-ignore - expect(browserOdpManager.eventManager.flushInterval).toBe(0); // Note: Browser event flush interval will always be 0 due to use of Pixel API - - // @ts-ignore - expect(browserOdpManager.eventManager.queueSize).toBe(1); - - // @ts-ignore - expect(browserOdpManager.eventManager.apiManager.requestHandler.timeout).toBe(1); - }); - - it('Custom odpOptions micro values (non-request/manager) override all expected fields for both segments and event managers', () => { - const odpOptions: OdpOptions = { - segmentsCacheSize: 4, - segmentsCacheTimeout: 4, - segmentsCache: new BrowserLRUCache({ maxSize: 4, timeout: 4 }), - segmentsApiTimeout: 4, - eventApiTimeout: 4, - eventBatchSize: 4, - eventFlushInterval: 4, - eventQueueSize: 4, - }; - - const browserOdpManager = BrowserOdpManager.createInstance({ - odpOptions, - }); - - // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.maxSize).toBe(4); - - // @ts-ignore - expect(browserOdpManager.segmentManager?._segmentsCache.timeout).toBe(4); - - // @ts-ignore - expect(browserOdpManager.segmentManager.odpSegmentApiManager.requestHandler.timeout).toBe(4); - - // @ts-ignore - expect(browserOdpManager.eventManager.batchSize).toBe(1); // Note: Browser batch size will always be 1 due to use of Pixel API - - // @ts-ignore - expect(browserOdpManager.eventManager.flushInterval).toBe(0); // Note: Browser event flush interval will always be 0 due to use of Pixel API - - // @ts-ignore - expect(browserOdpManager.eventManager.queueSize).toBe(4); - - // @ts-ignore - expect(browserOdpManager.eventManager.apiManager.requestHandler.timeout).toBe(4); - }); - }); -}); diff --git a/tests/odpManager.spec.ts b/tests/odpManager.spec.ts deleted file mode 100644 index 96f69b353..000000000 --- a/tests/odpManager.spec.ts +++ /dev/null @@ -1,698 +0,0 @@ -/** - * Copyright 2023-2024, 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 { describe, beforeEach, beforeAll, it, vi, expect } from 'vitest'; - -import { anything, capture, instance, mock, resetCalls, verify, when } from 'ts-mockito'; - -import { ERROR_MESSAGES, ODP_USER_KEY } from './../lib/utils/enums/index'; - -import { LogHandler, LogLevel } from '../lib/modules/logging'; -import { RequestHandler } from '../lib/utils/http_request_handler/http'; - -import { OdpManager, Status } from '../lib/odp/odp_manager'; -import { OdpConfig, OdpIntegratedConfig, OdpIntegrationConfig, OdpNotIntegratedConfig } from '../lib/odp/odp_config'; -import { NodeOdpEventApiManager as OdpEventApiManager } from '../lib/odp/event_manager/event_api_manager.node'; -import { NodeOdpEventManager as OdpEventManager } from '../lib/odp/event_manager/event_manager.node'; -import { IOdpSegmentManager, OdpSegmentManager } from './../lib/odp/segment_manager/odp_segment_manager'; -import { OdpSegmentApiManager } from '../lib/odp/segment_manager/odp_segment_api_manager'; -import { IOdpEventManager } from '../lib/shared_types'; -import { wait } from './testUtils'; -import { resolvablePromise } from '../lib/utils/promise/resolvablePromise'; - -const keyA = 'key-a'; -const hostA = 'host-a'; -const pixelA = 'pixel-a'; -const segmentsA = ['a']; -const userA = 'fs-user-a'; - -const keyB = 'key-b'; -const hostB = 'host-b'; -const pixelB = 'pixel-b'; -const segmentsB = ['b']; -const userB = 'fs-user-b'; - -const testOdpManager = ({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled, - vuid, - vuidInitializer, -}: { - odpIntegrationConfig?: OdpIntegrationConfig; - segmentManager: IOdpSegmentManager; - eventManager: IOdpEventManager; - logger: LogHandler; - vuidEnabled?: boolean; - vuid?: string; - vuidInitializer?: () => Promise; -}): OdpManager => { - class TestOdpManager extends OdpManager{ - constructor() { - super({ odpIntegrationConfig, segmentManager, eventManager, logger }); - } - isVuidEnabled(): boolean { - return vuidEnabled ?? false; - } - getVuid(): string { - return vuid ?? 'vuid_123'; - } - protected initializeVuid(): Promise { - return vuidInitializer?.() ?? Promise.resolve(); - } - } - return new TestOdpManager(); -} - -describe('OdpManager', () => { - let mockLogger: LogHandler; - let mockRequestHandler: RequestHandler; - - let odpConfig: OdpConfig; - let logger: LogHandler; - let defaultRequestHandler: RequestHandler; - - let mockEventApiManager: OdpEventApiManager; - let mockEventManager: OdpEventManager; - let mockSegmentApiManager: OdpSegmentApiManager; - let mockSegmentManager: OdpSegmentManager; - - let eventApiManager: OdpEventApiManager; - let eventManager: OdpEventManager; - let segmentApiManager: OdpSegmentApiManager; - let segmentManager: OdpSegmentManager; - - beforeAll(() => { - mockLogger = mock(); - mockRequestHandler = mock(); - - logger = instance(mockLogger); - defaultRequestHandler = instance(mockRequestHandler); - - mockEventApiManager = mock(); - mockEventManager = mock(); - mockSegmentApiManager = mock(); - mockSegmentManager = mock(); - - eventApiManager = instance(mockEventApiManager); - eventManager = instance(mockEventManager); - segmentApiManager = instance(mockSegmentApiManager); - segmentManager = instance(mockSegmentManager); - }); - - beforeEach(() => { - resetCalls(mockLogger); - resetCalls(mockRequestHandler); - resetCalls(mockEventApiManager); - resetCalls(mockEventManager); - resetCalls(mockSegmentManager); - }); - - - it('should be in stopped status and not ready if constructed without odpIntegrationConfig', () => { - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - }); - - expect(odpManager.isReady()).toBe(false); - expect(odpManager.getStatus()).toEqual(Status.Stopped); - }); - - it('should call initialzeVuid on construction if vuid is enabled', () => { - const vuidInitializer = vi.fn(); - - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - vuidInitializer: vuidInitializer, - }); - - expect(vuidInitializer).toHaveBeenCalledTimes(1); - }); - - it('should become ready only after odpIntegrationConfig is provided if vuid is not enabled', async () => { - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: false, - }); - - // should not be ready untill odpIntegrationConfig is provided - await wait(500); - expect(odpManager.isReady()).toBe(false); - - const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; - odpManager.updateSettings(odpIntegrationConfig); - - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); - }); - - it('should become ready if odpIntegrationConfig is provided in constructor and then initialzeVuid', async () => { - const vuidPromise = resolvablePromise(); - const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; - - const vuidInitializer = () => { - return vuidPromise.promise; - } - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - vuidInitializer, - }); - - await wait(500); - expect(odpManager.isReady()).toBe(false); - - vuidPromise.resolve(); - - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); - }); - - it('should become ready after odpIntegrationConfig is provided using updateSettings() and then initialzeVuid finishes', async () => { - const vuidPromise = resolvablePromise(); - - const vuidInitializer = () => { - return vuidPromise.promise; - } - - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - vuidInitializer, - }); - - - expect(odpManager.isReady()).toBe(false); - - const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; - odpManager.updateSettings(odpIntegrationConfig); - - await wait(500); - expect(odpManager.isReady()).toBe(false); - - vuidPromise.resolve(); - - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); - }); - - it('should become ready after initialzeVuid finishes and then odpIntegrationConfig is provided using updateSettings()', async () => { - const vuidPromise = resolvablePromise(); - - const vuidInitializer = () => { - return vuidPromise.promise; - } - - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - vuidInitializer, - }); - - expect(odpManager.isReady()).toBe(false); - vuidPromise.resolve(); - - await wait(500); - expect(odpManager.isReady()).toBe(false); - - const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; - odpManager.updateSettings(odpIntegrationConfig); - - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); - }); - - it('should become ready and stay in stopped state and not start eventManager if OdpNotIntegrated config is provided', async () => { - const vuidPromise = resolvablePromise(); - - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; - odpManager.updateSettings(odpIntegrationConfig); - - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); - expect(odpManager.getStatus()).toEqual(Status.Stopped); - verify(mockEventManager.start()).never(); - }); - - it('should pass the integrated odp config given in constructor to eventManger and segmentManager', async () => { - when(mockEventManager.updateSettings(anything())).thenReturn(undefined); - when(mockSegmentManager.updateSettings(anything())).thenReturn(undefined); - - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - verify(mockEventManager.updateSettings(anything())).once(); - const [eventOdpConfig] = capture(mockEventManager.updateSettings).first(); - expect(eventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); - - verify(mockSegmentManager.updateSettings(anything())).once(); - const [segmentOdpConfig] = capture(mockEventManager.updateSettings).first(); - expect(segmentOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); - }); - - it('should pass the integrated odp config given in updateSettings() to eventManger and segmentManager', async () => { - when(mockEventManager.updateSettings(anything())).thenReturn(undefined); - when(mockSegmentManager.updateSettings(anything())).thenReturn(undefined); - - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - odpManager.updateSettings(odpIntegrationConfig); - - verify(mockEventManager.updateSettings(anything())).once(); - const [eventOdpConfig] = capture(mockEventManager.updateSettings).first(); - expect(eventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); - - verify(mockSegmentManager.updateSettings(anything())).once(); - const [segmentOdpConfig] = capture(mockEventManager.updateSettings).first(); - expect(segmentOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); - }); - - it('should start if odp is integrated and start odpEventManger', async () => { - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - odpManager.updateSettings(odpIntegrationConfig); - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); - expect(odpManager.getStatus()).toEqual(Status.Running); - }); - - it('should just update config when updateSettings is called in running state', async () => { - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - odpManager.updateSettings(odpIntegrationConfig); - - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); - expect(odpManager.getStatus()).toEqual(Status.Running); - - const newOdpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyB, hostB, pixelB, segmentsB) - }; - - odpManager.updateSettings(newOdpIntegrationConfig); - - verify(mockEventManager.start()).once(); - verify(mockEventManager.stop()).never(); - verify(mockEventManager.updateSettings(anything())).twice(); - const [firstEventOdpConfig] = capture(mockEventManager.updateSettings).first(); - expect(firstEventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); - const [secondEventOdpConfig] = capture(mockEventManager.updateSettings).second(); - expect(secondEventOdpConfig.equals(newOdpIntegrationConfig.odpConfig)).toBe(true); - - verify(mockSegmentManager.updateSettings(anything())).twice(); - const [firstSegmentOdpConfig] = capture(mockEventManager.updateSettings).first(); - expect(firstSegmentOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); - const [secondSegmentOdpConfig] = capture(mockEventManager.updateSettings).second(); - expect(secondSegmentOdpConfig.equals(newOdpIntegrationConfig.odpConfig)).toBe(true); - }); - - it('should stop and stop eventManager if OdpNotIntegrated config is updated in running state', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - expect(odpManager.isReady()).toBe(true); - expect(odpManager.getStatus()).toEqual(Status.Running); - - const newOdpIntegrationConfig: OdpNotIntegratedConfig = { - integrated: false, - }; - - odpManager.updateSettings(newOdpIntegrationConfig); - - expect(odpManager.getStatus()).toEqual(Status.Stopped); - verify(mockEventManager.stop()).once(); - }); - - it('should register vuid after becoming ready if odp is integrated', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - verify(mockEventManager.registerVuid(anything())).once(); - }); - - it('should call eventManager.identifyUser with correct parameters when identifyUser is called', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - const userId = 'user123'; - const vuid = 'vuid_123'; - - odpManager.identifyUser(userId, vuid); - const [userIdArg, vuidArg] = capture(mockEventManager.identifyUser).byCallIndex(0); - expect(userIdArg).toEqual(userId); - expect(vuidArg).toEqual(vuid); - - odpManager.identifyUser(userId); - const [userIdArg2, vuidArg2] = capture(mockEventManager.identifyUser).byCallIndex(1); - expect(userIdArg2).toEqual(userId); - expect(vuidArg2).toEqual(undefined); - - odpManager.identifyUser(vuid); - const [userIdArg3, vuidArg3] = capture(mockEventManager.identifyUser).byCallIndex(2); - expect(userIdArg3).toEqual(undefined); - expect(vuidArg3).toEqual(vuid); - }); - - it('should send event with correct parameters', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - const identifiers = new Map([['email', 'a@b.com']]); - const data = new Map([['key1', 'value1'], ['key2', 'value2']]); - - odpManager.sendEvent({ - action: 'action', - type: 'type', - identifiers, - data, - }); - - const [event] = capture(mockEventManager.sendEvent).byCallIndex(0); - expect(event.action).toEqual('action'); - expect(event.type).toEqual('type'); - expect(event.identifiers).toEqual(identifiers); - expect(event.data).toEqual(data); - - // should use `fullstack` as type if empty string is provided - odpManager.sendEvent({ - type: '', - action: 'action', - identifiers, - data, - }); - - const [event2] = capture(mockEventManager.sendEvent).byCallIndex(1); - expect(event2.action).toEqual('action'); - expect(event2.type).toEqual('fullstack'); - expect(event2.identifiers).toEqual(identifiers); - }); - - - it('should throw an error if event action is empty string and not call eventManager', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - const identifiers = new Map([['email', 'a@b.com']]); - const data = new Map([['key1', 'value1'], ['key2', 'value2']]); - - const sendEvent = () => odpManager.sendEvent({ - action: '', - type: 'type', - identifiers, - data, - }); - - expect(sendEvent).toThrow('ODP action is not valid'); - verify(mockEventManager.sendEvent(anything())).never(); - }); - - it('should throw an error if event data is invalid', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - const identifiers = new Map([['email', 'a@b.com']]); - const data = new Map([['key1', {}]]); - - const sendEvent = () => odpManager.sendEvent({ - action: 'action', - type: 'type', - identifiers, - data, - }); - - expect(sendEvent).toThrow(ERROR_MESSAGES.ODP_INVALID_DATA); - verify(mockEventManager.sendEvent(anything())).never(); - }); - - it('should fetch qualified segments correctly for both fs_user_id and vuid', async () => { - const userId = 'user123'; - const vuid = 'vuid_123'; - - when(mockSegmentManager.fetchQualifiedSegments(ODP_USER_KEY.FS_USER_ID, userId, anything())) - .thenResolve(['fs1', 'fs2']); - - when(mockSegmentManager.fetchQualifiedSegments(ODP_USER_KEY.VUID, vuid, anything())) - .thenResolve(['vuid1', 'vuid2']); - - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager: instance(mockSegmentManager), - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - const fsSegments = await odpManager.fetchQualifiedSegments(userId); - expect(fsSegments).toEqual(['fs1', 'fs2']); - - const vuidSegments = await odpManager.fetchQualifiedSegments(vuid); - expect(vuidSegments).toEqual(['vuid1', 'vuid2']); - }); - - - it('should stop itself and eventManager if stop is called', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - odpManager.stop(); - - expect(odpManager.getStatus()).toEqual(Status.Stopped); - verify(mockEventManager.stop()).once(); - }); - - - - it('should drop relevant calls and log error when odpIntegrationConfig is not available', async () => { - const odpManager = testOdpManager({ - odpIntegrationConfig: undefined, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - const segments = await odpManager.fetchQualifiedSegments('vuid_user1', []); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).once(); - expect(segments).toBeNull(); - - odpManager.identifyUser('vuid_user1'); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).twice(); - verify(mockEventManager.identifyUser(anything(), anything())).never(); - - const identifiers = new Map([['email', 'a@b.com']]); - const data = new Map([['key1', {}]]); - - odpManager.sendEvent({ - action: 'action', - type: 'type', - identifiers, - data, - }); - - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE)).thrice(); - verify(mockEventManager.sendEvent(anything())).never(); - - }); - - it('should drop relevant calls and log error when odp is not integrated', async () => { - const odpManager = testOdpManager({ - odpIntegrationConfig: { integrated: false }, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); - - await odpManager.onReady(); - - const segments = await odpManager.fetchQualifiedSegments('vuid_user1', []); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).once(); - expect(segments).toBeNull(); - - odpManager.identifyUser('vuid_user1'); - verify(mockLogger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).once(); - verify(mockEventManager.identifyUser(anything(), anything())).never(); - - const identifiers = new Map([['email', 'a@b.com']]); - const data = new Map([['key1', {}]]); - - odpManager.sendEvent({ - action: 'action', - type: 'type', - identifiers, - data, - }); - - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).twice(); - verify(mockEventManager.sendEvent(anything())).never(); - }); -}); - diff --git a/tests/odpSegmentApiManager.spec.ts b/tests/odpSegmentApiManager.spec.ts deleted file mode 100644 index ee8ebc482..000000000 --- a/tests/odpSegmentApiManager.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Copyright 2022-2024 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 { describe, beforeEach, beforeAll, it, expect } from 'vitest'; - -import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; -import { LogHandler, LogLevel } from '../lib/modules/logging'; -import { OdpSegmentApiManager } from '../lib/odp/segment_manager/odp_segment_api_manager'; -import { RequestHandler } from '../lib/utils/http_request_handler/http'; -import { ODP_USER_KEY } from '../lib/utils/enums'; - -const API_key = 'not-real-api-key'; -const GRAPHQL_ENDPOINT = 'https://some.example.com/graphql/endpoint'; -const USER_KEY = ODP_USER_KEY.FS_USER_ID; -const USER_VALUE = 'tester-101'; -const SEGMENTS_TO_CHECK = ['has_email', 'has_email_opted_in', 'push_on_sale']; - -describe('OdpSegmentApiManager', () => { - let mockLogger: LogHandler; - let mockRequestHandler: RequestHandler; - - beforeAll(() => { - mockLogger = mock(); - mockRequestHandler = mock(); - }); - - beforeEach(() => { - resetCalls(mockLogger); - resetCalls(mockRequestHandler); - }); - - const managerInstance = () => new OdpSegmentApiManager(instance(mockRequestHandler), instance(mockLogger)); - - const abortableRequest = (statusCode: number, body: string) => { - return { - abort: () => {}, - responsePromise: Promise.resolve({ - statusCode, - body, - headers: {}, - }), - }; - }; - - 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 = managerInstance(); - - const response = manager['parseSegmentsResponseJson'](validJsonResponse); - - 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 = mock_user_id", - "locations": [ - { - "line": 2, - "column": 3 - } - ], - "path": [ - "customer" - ], - "extensions": { - "classification": "InvalidIdentifierException" - } - } - ], - "data": { - "customer": null - } -}`; - const manager = managerInstance(); - - const response = manager['parseSegmentsResponseJson'](errorJsonResponse); - - expect(response).not.toBeUndefined(); - expect(response?.data.customer).toBeNull(); - expect(response?.errors).not.toBeNull(); - expect(response?.errors[0].extensions.classification).toEqual('InvalidIdentifierException'); - }); - - it('should construct a valid GraphQL query string', () => { - const manager = managerInstance(); - - const response = manager['toGraphQLJson'](USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(response).toBe( - `{"query" : "query {customer(${USER_KEY} : \\"${USER_VALUE}\\") {audiences(subset: [\\"has_email\\",\\"has_email_opted_in\\",\\"push_on_sale\\"]) {edges {node {name state}}}}}"}` - ); - }); - - 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(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, responseJsonWithQualifiedSegments) - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(segments?.length).toEqual(2); - expect(segments).toContain('has_email'); - expect(segments).toContain('has_email_opted_in'); - verify(mockLogger.log(anything(), anyString())).never(); - }); - - it('should handle a request to query no segments', async () => { - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, ODP_USER_KEY.FS_USER_ID, USER_VALUE, []); - - expect(segments?.length).toEqual(0); - verify(mockLogger.log(anything(), anyString())).never(); - }); - - it('should handle empty qualified segments', async () => { - const responseJsonWithNoQualifiedSegments = '{"data":{"customer":{"audiences":' + '{"edges":[ ]}}}}'; - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, responseJsonWithNoQualifiedSegments) - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(segments?.length).toEqual(0); - 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":{"code": "INVALID_IDENTIFIER_EXCEPTION","classification":"DataFetchingException"}}],' + - '"data":{"customer":null}}'; - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, errorJsonResponse) - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments( - API_key, - GRAPHQL_ENDPOINT, - USER_KEY, - INVALID_USER_ID, - SEGMENTS_TO_CHECK - ); - - expect(segments).toBeNull(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (invalid identifier)')).once(); - }); - - it('should handle other fetch error responses', 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(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, errorJsonResponse) - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments( - API_key, - GRAPHQL_ENDPOINT, - USER_KEY, - INVALID_USER_ID, - SEGMENTS_TO_CHECK - ); - - expect(segments).toBeNull(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (DataFetchingException)')).once(); - }); - - it('should handle unrecognized JSON responses', async () => { - const unrecognizedJson = '{"unExpectedObject":{ "withSome": "value", "thatIsNotParseable": "true" }}'; - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, unrecognizedJson) - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(segments).toBeNull(); - 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(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, errorJsonResponse) - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(segments).toBeNull(); - verify(mockLogger.log(anything(), anyString())).once(); - }); - - it('should handle bad responses', async () => { - const badResponse = '{"data":{ }}'; - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(200, badResponse) - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(segments).toBeNull(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)')).once(); - }); - - it('should handle non 200 HTTP status code response', async () => { - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn( - abortableRequest(400, '') - ); - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(segments).toBeNull(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); - }); - - it('should handle a timeout', async () => { - when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => {}, - responsePromise: Promise.reject(new Error('Request timed out')), - }); - const manager = managerInstance(); - - const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); - - expect(segments).toBeNull(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); - }); -}); diff --git a/tests/odpSegmentManager.spec.ts b/tests/odpSegmentManager.spec.ts deleted file mode 100644 index f10dbc353..000000000 --- a/tests/odpSegmentManager.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Copyright 2022-2024, 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 { describe, beforeEach, it, expect } from 'vitest'; - -import { mock, resetCalls, instance } from 'ts-mockito'; - -import { LogHandler } from '../lib/modules/logging'; -import { ODP_USER_KEY } from '../lib/utils/enums'; -import { RequestHandler } from '../lib/utils/http_request_handler/http'; - -import { OdpSegmentManager } from '../lib/odp/segment_manager/odp_segment_manager'; -import { OdpConfig } from '../lib/odp/odp_config'; -import { LRUCache } from '../lib/utils/lru_cache'; -import { OptimizelySegmentOption } from './../lib/odp/segment_manager/optimizely_segment_option'; -import { OdpSegmentApiManager } from '../lib/odp/segment_manager/odp_segment_api_manager'; - -describe('OdpSegmentManager', () => { - class MockOdpSegmentApiManager extends OdpSegmentApiManager { - async fetchSegments( - apiKey: string, - apiHost: string, - userKey: ODP_USER_KEY, - userValue: string, - segmentsToCheck: string[] - ): Promise { - if (apiKey == 'invalid-key') return null; - return segmentsToCheck; - } - } - - const mockLogHandler = mock(); - const mockRequestHandler = mock(); - - const apiManager = new MockOdpSegmentApiManager(instance(mockRequestHandler), instance(mockLogHandler)); - - let options: Array = []; - - const userKey: ODP_USER_KEY = ODP_USER_KEY.VUID; - const userValue = 'test-user'; - - const validTestOdpConfig = new OdpConfig('valid-key', 'host', 'pixel-url', ['new-customer']); - const invalidTestOdpConfig = new OdpConfig('invalid-key', 'host', 'pixel-url', ['new-customer']); - - const getSegmentsCache = () => { - return new LRUCache({ - maxSize: 1000, - timeout: 1000, - }); - } - - beforeEach(() => { - resetCalls(mockLogHandler); - resetCalls(mockRequestHandler); - - const API_KEY = 'test-api-key'; - const API_HOST = 'https://odp.example.com'; - const PIXEL_URL = 'https://odp.pixel.com'; - }); - - it('should fetch segments successfully on cache miss.', async () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - setCache(manager, userKey, '123', ['a']); - - const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); - expect(segments).toEqual(['new-customer']); - }); - - it('should fetch segments successfully on cache hit.', async () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - setCache(manager, userKey, userValue, ['a']); - - const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); - expect(segments).toEqual(['a']); - }); - - it('should return null when fetching segments returns an error.', async () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, invalidTestOdpConfig); - - const segments = await manager.fetchQualifiedSegments(userKey, userValue, []); - expect(segments).toBeNull; - }); - - it('should ignore the cache if the option enum is included in the options array.', async () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - setCache(manager, userKey, userValue, ['a']); - options = [OptimizelySegmentOption.IGNORE_CACHE]; - - const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); - expect(segments).toEqual(['new-customer']); - expect(cacheCount(manager)).toBe(1); - }); - - it('should ignore the cache if the option string is included in the options array.', async () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - setCache(manager,userKey, userValue, ['a']); - // @ts-ignore - options = ['IGNORE_CACHE']; - - const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); - expect(segments).toEqual(['new-customer']); - expect(cacheCount(manager)).toBe(1); - }); - - it('should reset the cache if the option enum is included in the options array.', async () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - setCache(manager, userKey, userValue, ['a']); - setCache(manager, userKey, '123', ['a']); - setCache(manager, userKey, '456', ['a']); - options = [OptimizelySegmentOption.RESET_CACHE]; - - const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); - expect(segments).toEqual(['new-customer']); - expect(peekCache(manager, userKey, userValue)).toEqual(segments); - expect(cacheCount(manager)).toBe(1); - }); - - it('should reset the cache on settings update.', async () => { - const oldConfig = new OdpConfig('old-key', 'old-host', 'pixel-url', ['new-customer']); - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - - setCache(manager, userKey, userValue, ['a']); - expect(cacheCount(manager)).toBe(1); - - const newConfig = new OdpConfig('new-key', 'new-host', 'pixel-url', ['new-customer']); - manager.updateSettings(newConfig); - - expect(cacheCount(manager)).toBe(0); - }); - - it('should reset the cache if the option string is included in the options array.', async () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - setCache(manager, userKey, userValue, ['a']); - setCache(manager, userKey, '123', ['a']); - setCache(manager, userKey, '456', ['a']); - // @ts-ignore - options = ['RESET_CACHE']; - - const segments = await manager.fetchQualifiedSegments(userKey, userValue, options); - expect(segments).toEqual(['new-customer']); - expect(peekCache(manager, userKey, userValue)).toEqual(segments); - expect(cacheCount(manager)).toBe(1); - }); - - it('should make a valid cache key.', () => { - const manager = new OdpSegmentManager(getSegmentsCache(), apiManager, mockLogHandler, validTestOdpConfig); - expect('vuid-$-test-user').toBe(manager.makeCacheKey(userKey, userValue)); - }); - - // Utility Functions - - function setCache(manager: OdpSegmentManager, userKey: string, userValue: string, value: string[]) { - const cacheKey = manager.makeCacheKey(userKey, userValue); - manager.segmentsCache.save({ - key: cacheKey, - value, - }); - } - - function peekCache(manager: OdpSegmentManager, userKey: string, userValue: string): string[] | null { - const cacheKey = manager.makeCacheKey(userKey, userValue); - return (manager.segmentsCache as LRUCache).peek(cacheKey); - } - - const cacheCount = (manager: OdpSegmentManager) => (manager.segmentsCache as LRUCache).map.size; -}); diff --git a/tests/vuidManager.spec.ts b/tests/vuidManager.spec.ts deleted file mode 100644 index 2f412fe02..000000000 --- a/tests/vuidManager.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright 2022, 2024, 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 { describe, beforeEach, beforeAll, it, expect } from 'vitest'; - -import { VuidManager } from '../lib/plugins/vuid_manager'; -import PersistentKeyValueCache from '../lib/plugins/key_value_cache/persistentKeyValueCache'; -import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; - -describe('VuidManager', () => { - let mockCache: PersistentKeyValueCache; - - beforeAll(() => { - mockCache = mock(); - when(mockCache.contains(anyString())).thenResolve(true); - when(mockCache.get(anyString())).thenResolve(''); - when(mockCache.remove(anyString())).thenResolve(true); - when(mockCache.set(anyString(), anything())).thenResolve(); - VuidManager.instance(instance(mockCache)); - }); - - beforeEach(() => { - resetCalls(mockCache); - VuidManager['_reset'](); - }); - - it('should make a VUID', async () => { - const manager = await VuidManager.instance(instance(mockCache)); - - const vuid = manager['makeVuid'](); - - expect(vuid.startsWith('vuid_')).toBe(true); - expect(vuid.length).toEqual(32); - expect(vuid).not.toContain('-'); - }); - - it('should test if a VUID is valid', async () => { - const manager = await VuidManager.instance(instance(mockCache)); - - expect(VuidManager.isVuid('vuid_123')).toBe(true); - expect(VuidManager.isVuid('vuid-123')).toBe(false); - expect(VuidManager.isVuid('123')).toBe(false); - }); - - it('should auto-save and auto-load', async () => { - const cache = instance(mockCache); - - await cache.remove('optimizely-odp'); - - const manager1 = await VuidManager.instance(cache); - const vuid1 = manager1.vuid; - - const manager2 = await VuidManager.instance(cache); - const vuid2 = manager2.vuid; - - expect(vuid1).toStrictEqual(vuid2); - expect(VuidManager.isVuid(vuid1)).toBe(true); - expect(VuidManager.isVuid(vuid2)).toBe(true); - - await cache.remove('optimizely-odp'); - - // should end up being a new instance since we just removed it above - await manager2['load'](cache); - const vuid3 = manager2.vuid; - - expect(vuid3).not.toStrictEqual(vuid1); - expect(VuidManager.isVuid(vuid3)).toBe(true); - }); - - it('should handle no valid optimizely-vuid in the cache', async () => { - when(mockCache.get(anyString())).thenResolve(undefined); - - const manager = await VuidManager.instance(instance(mockCache)); // load() called initially - - verify(mockCache.get(anyString())).once(); - verify(mockCache.set(anyString(), anything())).once(); - expect(VuidManager.isVuid(manager.vuid)).toBe(true); - }); - - it('should create a new vuid if old VUID from cache is not valid', async () => { - when(mockCache.get(anyString())).thenResolve('vuid-not-valid'); - - const manager = await VuidManager.instance(instance(mockCache)); - - verify(mockCache.get(anyString())).once(); - verify(mockCache.set(anyString(), anything())).once(); - expect(VuidManager.isVuid(manager.vuid)).toBe(true); - }); -});