diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fac0178..277879c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Set up nodejs uses: actions/setup-node@v2 with: - node-version: 'lts/*' + node-version: '14' cache: 'npm' - name: npm CI diff --git a/.github/workflows/update-license-year.yml b/.github/workflows/update-license-year.yml new file mode 100644 index 00000000..c8fd1684 --- /dev/null +++ b/.github/workflows/update-license-year.yml @@ -0,0 +1,49 @@ +name: Update License Year + +on: + schedule: + - cron: "0 3 1 1 *" # 03:00 AM on January 1 + push: + branches: + - main + - master + +permissions: + contents: write + pull-requests: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set Current year + run: "echo CURRENT=$(date +%Y) >> $GITHUB_ENV" + + - name: Set Previous Year + run: "echo PREVIOUS=$(($CURRENT-1)) >> $GITHUB_ENV" + + - name: Update LICENSE + uses: jacobtomlinson/gha-find-replace@v2 + with: + find: ${{ env.PREVIOUS }} + replace: ${{ env.CURRENT }} + include: "LICENSE" + regex: false + + - name: Commit files + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git commit -m "Updated License Year" -a + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + title: Update License Year + branch: update-license diff --git a/CHANGES.txt b/CHANGES.txt index 892a6b9d..7bf86465 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,17 @@ +1.3.0 (April 6, 2022) + - Added user consent feature to allow delaying or disabling the data tracking from SDK until user consent is explicitly granted or declined. Read more in our docs. + - Added `scheduler.impressionsQueueSize` property to SDK configuration to limit the amount of impressions tracked in memory. Read more in our docs. + - Added support to accept TLS configuration options to the Redis storage in NodeJS. Read more in our docs. + - Updated format for MySegments keys in LocalStorage, keeping backwards compatibility (issue https://github.com/splitio/javascript-client/issues/638). + - Updated some modules due to general polishing and refactors, including updates in some log messages. + - Updated some dependencies for vulnerability fixes. + - Bugfixing - Updated internal isObject utility function, to avoid unexpected behaviors on frameworks and libraries that uses multiple VM contexts, like NuxtJS dev server. + - Bugfixing - Fixed validation of `core.key` SDK configuration param, to parse it into a string and log a warning when passing a number (Related to issue https://github.com/splitio/react-native-client/issues/19). + - Bugfixing - Fixed validation of `sync.impressionsMode` SDK configuration param, to avoid an exception on SplitFactory instantiation when passing a non-string value. + - Bugfixing - Fixed an issue with `connectionTimeout` options params of Redis storage, that was being ignored and not passed down to the underlying ioredis client. + - Bugfixing - Fixed streaming synchronization issue with multiple clients. + - Bugfixing - Fixed issue with internal Map ponyfill that results in logger not working properly on IE11 browser. + 1.2.0 (January 19, 2022) - Added support to SDK clients on browser to optionally bind attributes to the client, keeping these loaded within the SDK along with the user ID, for easier usage when requesting flag. diff --git a/package-lock.json b/package-lock.json index 7be002a0..a53eec0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -373,6 +373,15 @@ "@babel/helper-plugin-utils": "^7.14.5" } }, + "@babel/runtime": { + "version": "7.17.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz", + "integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, "@babel/template": { "version": "7.15.4", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.15.4.tgz", @@ -1657,30 +1666,6 @@ "babel-preset-current-node-syntax": "^1.0.0" } }, - "babel-runtime": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", - "dev": true, - "requires": { - "core-js": "^2.4.0", - "regenerator-runtime": "^0.11.0" - }, - "dependencies": { - "core-js": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", - "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", - "dev": true - }, - "regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "dev": true - } - } - }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -2793,12 +2778,13 @@ } }, "fetch-mock": { - "version": "9.10.7", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.10.7.tgz", - "integrity": "sha512-YkiMHSL8CQ0vlWYpqGvlaZjViFk0Kar9jonPjSvaWoztkeHH6DENqUzBIsffzjVKhwchPI74SZRLRpIsEyNcZQ==", + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", + "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", "dev": true, "requires": { - "babel-runtime": "^6.26.0", + "@babel/core": "^7.0.0", + "@babel/runtime": "^7.0.0", "core-js": "^3.0.0", "debug": "^4.1.1", "glob-to-regexp": "^0.4.0", @@ -2809,21 +2795,6 @@ "whatwg-url": "^6.5.0" }, "dependencies": { - "debug": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", - "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, "tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -5013,9 +4984,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "ms": { @@ -5031,10 +5002,37 @@ "dev": true }, "node-fetch": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", - "dev": true + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", + "dev": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } }, "node-int64": { "version": "0.4.0", @@ -5314,9 +5312,9 @@ "dev": true }, "querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", + "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", "dev": true }, "react-is": { @@ -5370,6 +5368,12 @@ "promise-queue": "^2.2.5" } }, + "regenerator-runtime": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", + "dev": true + }, "regexpp": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", @@ -5912,9 +5916,9 @@ } }, "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" }, "tsutils": { "version": "3.17.1", @@ -5964,9 +5968,9 @@ } }, "typescript": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.2.tgz", - "integrity": "sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz", + "integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==", "dev": true }, "unbox-primitive": { diff --git a/package.json b/package.json index 9310f51f..074589a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.2.0", + "version": "1.3.0", "description": "Split Javascript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", @@ -20,8 +20,8 @@ "check:lint": "eslint src --ext .js,.ts", "check:types": "tsc --noEmit", "build": "npm run build:cjs && npm run build:esm", - "build:esm": "rimraf esm && tsc -m es2015 --outDir esm -d true --declarationDir types --importHelpers", - "build:cjs": "rimraf cjs && tsc -m CommonJS --outDir cjs --importHelpers", + "build:esm": "rimraf esm && tsc -m es2015 --outDir esm -d true --declarationDir types", + "build:cjs": "rimraf cjs && tsc -m CommonJS --outDir cjs", "test": "jest", "test:coverage": "jest --coverage", "publish:rc": "npm run check && npm run test && npm run build && npm publish --tag rc", @@ -44,7 +44,7 @@ "bugs": "https://github.com/splitio/javascript-commons/issues", "homepage": "https://github.com/splitio/javascript-commons#readme", "dependencies": { - "tslib": "^2.1.0" + "tslib": "^2.3.1" }, "devDependencies": { "@types/google.analytics": "0.0.40", @@ -58,17 +58,17 @@ "eslint": "^7.32.0", "eslint-plugin-compat": "3.7.0", "eslint-plugin-import": "^2.25.3", - "fetch-mock": "^9.10.7", + "fetch-mock": "^9.11.0", "ioredis": "^4.28.0", "jest": "^27.2.3", "jest-localstorage-mock": "^2.4.3", "js-yaml": "^3.14.0", "lodash": "^4.17.21", - "node-fetch": "^2.6.1", + "node-fetch": "^2.6.7", "redis-server": "1.2.2", "rimraf": "^3.0.2", "ts-jest": "^27.0.5", - "typescript": "^4.0.2" + "typescript": "4.4.4" }, "sideEffects": false } diff --git a/src/__tests__/testUtils/fetchMock.ts b/src/__tests__/testUtils/fetchMock.ts index 42a0292e..94a614f7 100644 --- a/src/__tests__/testUtils/fetchMock.ts +++ b/src/__tests__/testUtils/fetchMock.ts @@ -1,6 +1,7 @@ -import { sandbox } from 'fetch-mock'; +// http://www.wheresrhys.co.uk/fetch-mock/#usageinstallation +import fetchMockLib from 'fetch-mock'; -const fetchMock = sandbox(); +const fetchMock = fetchMockLib.sandbox(); // config the fetch mock to chain routes (appends the new route to the list of routes) fetchMock.config.overwriteRoutes = false; diff --git a/src/consent/__tests__/sdkUserConsent.spec.ts b/src/consent/__tests__/sdkUserConsent.spec.ts new file mode 100644 index 00000000..706b6ca9 --- /dev/null +++ b/src/consent/__tests__/sdkUserConsent.spec.ts @@ -0,0 +1,45 @@ +import { createUserConsentAPI } from '../sdkUserConsent'; +import { syncTaskFactory } from '../../sync/__tests__/syncTask.mock'; +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; + +test('createUserConsentAPI', () => { + const settings = { ...fullSettings, userConsent: 'UNKNOWN' }; + const syncManager = { submitter: syncTaskFactory() }; + const storage = { + events: { clear: jest.fn() }, + impressions: { clear: jest.fn() } + }; + + // @ts-ignore + const props = createUserConsentAPI({ settings, syncManager, storage }); + + // getUserConsent returns settings.userConsent + expect(props.getStatus()).toBe(settings.userConsent); + expect(props.getStatus()).toBe(props.Status.UNKNOWN); + + // setting user consent to 'GRANTED' + expect(props.setStatus(true)).toBe(true); + expect(props.setStatus(true)).toBe(true); // calling again has no affect + expect(syncManager.submitter.start).toBeCalledTimes(1); // submitter resumed + expect(syncManager.submitter.stop).toBeCalledTimes(0); + expect(props.getStatus()).toBe(props.Status.GRANTED); + + // setting user consent to 'DECLINED' + expect(props.setStatus(false)).toBe(true); + expect(props.setStatus(false)).toBe(true); // calling again has no affect + expect(syncManager.submitter.start).toBeCalledTimes(1); + expect(syncManager.submitter.stop).toBeCalledTimes(1); // submitter paused + expect(props.getStatus()).toBe(props.Status.DECLINED); + expect(storage.events.clear).toBeCalledTimes(1); // storage tracked data dropped + expect(storage.impressions.clear).toBeCalledTimes(1); + + // Invalid values have no effect + expect(props.setStatus('DECLINED')).toBe(false); // strings are not valid + expect(props.setStatus('GRANTED')).toBe(false); + expect(props.setStatus(undefined)).toBe(false); + expect(props.setStatus({})).toBe(false); + + expect(syncManager.submitter.start).toBeCalledTimes(1); + expect(syncManager.submitter.stop).toBeCalledTimes(1); + expect(props.getStatus()).toBe(props.Status.DECLINED); +}); diff --git a/src/consent/index.ts b/src/consent/index.ts new file mode 100644 index 00000000..3de8d7d5 --- /dev/null +++ b/src/consent/index.ts @@ -0,0 +1,8 @@ +import { ISettings } from '../types'; +import { CONSENT_GRANTED } from '../utils/constants'; + +export function isConsentGranted(settings: ISettings) { + const userConsent = settings.userConsent; + // undefined userConsent is handled as granted (default) + return !userConsent || userConsent === CONSENT_GRANTED; +} diff --git a/src/consent/sdkUserConsent.ts b/src/consent/sdkUserConsent.ts new file mode 100644 index 00000000..cd7d387f --- /dev/null +++ b/src/consent/sdkUserConsent.ts @@ -0,0 +1,58 @@ +import { ERROR_NOT_BOOLEAN, USER_CONSENT_UPDATED, USER_CONSENT_NOT_UPDATED, USER_CONSENT_INITIAL } from '../logger/constants'; +import { isConsentGranted } from './index'; +import { CONSENT_GRANTED, CONSENT_DECLINED, CONSENT_UNKNOWN } from '../utils/constants'; +import { isBoolean } from '../utils/lang'; +import { ISdkFactoryContext } from '../sdkFactory/types'; + +// User consent enum +const ConsentStatus = { + GRANTED: CONSENT_GRANTED, + DECLINED: CONSENT_DECLINED, + UNKNOWN: CONSENT_UNKNOWN, +}; + +/** + * The public user consent API exposed via SplitFactory, used to control if the SDK tracks and sends impressions and events or not. + */ +export function createUserConsentAPI(params: ISdkFactoryContext) { + const { settings, settings: { log }, syncManager, storage: { events, impressions, impressionCounts } } = params; + + if (!isConsentGranted(settings)) log.info(USER_CONSENT_INITIAL, [settings.userConsent]); + + return { + setStatus(consent: unknown) { + // validate input param + if (!isBoolean(consent)) { + log.warn(ERROR_NOT_BOOLEAN, ['setUserConsent']); + return false; + } + + const newConsentStatus = consent ? CONSENT_GRANTED : CONSENT_DECLINED; + + if (settings.userConsent !== newConsentStatus) { + log.info(USER_CONSENT_UPDATED, [settings.userConsent, newConsentStatus]); // @ts-ignore, modify readonly prop + settings.userConsent = newConsentStatus; + + if (consent) { // resumes submitters if transitioning to GRANTED + syncManager?.submitter?.start(); + } else { // pauses submitters and drops tracked data if transitioning to DECLINED + syncManager?.submitter?.stop(); + // @ts-ignore, clear method is present in storage for standalone and partial consumer mode + if (events.clear) events.clear(); // @ts-ignore + if (impressions.clear) impressions.clear(); + if (impressionCounts) impressionCounts.clear(); + } + } else { + log.info(USER_CONSENT_NOT_UPDATED, [newConsentStatus]); + } + + return true; + }, + + getStatus() { + return settings.userConsent; + }, + + Status: ConsentStatus + }; +} diff --git a/src/evaluator/condition/__tests__/engineUtils.spec.ts b/src/evaluator/condition/__tests__/engineUtils.spec.ts index 8682c851..72753bbd 100644 --- a/src/evaluator/condition/__tests__/engineUtils.spec.ts +++ b/src/evaluator/condition/__tests__/engineUtils.spec.ts @@ -18,7 +18,6 @@ test('ENGINE / should always evaluate to "off"', () => { expect(engineUtils.getTreatment(loggerMock, bucketingKey, seed, treatmentsMock) === 'off').toBe(true); // treatment should be 'off' - let endTime = Date.now(); console.log(`Evaluation takes ${(endTime - startTime) / 1000} seconds`); @@ -38,28 +37,24 @@ test('ENGINE / should always evaluate to "on"', () => { }); test('ENGINE / shouldApplyRollout - trafficAllocation 100', () => { - const shouldApplyRollout = engineUtils.shouldApplyRollout(100, 'asd', 14); expect(shouldApplyRollout).toBe(true); // Should return true as traffic allocation is 100. }); test('ENGINE / shouldApplyRollout - algo murmur | trafficAllocation 53 | bucket 51', () => { - const shouldApplyRollout = engineUtils.shouldApplyRollout(53, 'a', 29); expect(shouldApplyRollout).toBe(true); // Should return true as traffic allocation is 100. }); test('ENGINE / shouldApplyRollout - algo murmur | trafficAllocation 53 | bucket 56', () => { - const shouldApplyRollout = engineUtils.shouldApplyRollout(53, 'a', 31); expect(shouldApplyRollout).toBe(false); // Should return false as bucket is higher than trafficAllocation. }); test('ENGINE / shouldApplyRollout - algo murmur | trafficAllocation 1 | bucket 1', () => { - const shouldApplyRollout = engineUtils.shouldApplyRollout(1, 'aaaaaaklmnbv', -1667452163); expect(shouldApplyRollout).toBe(true); // Should return true as bucket is higher or equal than trafficAllocation. diff --git a/src/evaluator/matchers/ew.ts b/src/evaluator/matchers/ew.ts index 851e0e2c..6afdc9e1 100644 --- a/src/evaluator/matchers/ew.ts +++ b/src/evaluator/matchers/ew.ts @@ -1,13 +1,13 @@ import { ENGINE_MATCHER_ENDS_WITH } from '../../logger/constants'; import { ILogger } from '../../logger/types'; -import { endsWith as strEndsWith } from '../../utils/lang'; +import { endsWith } from '../../utils/lang'; export function endsWithMatcherContext(log: ILogger, ruleAttr: string[]) /*: Function */ { return function endsWithMatcher(runtimeAttr: string): boolean { - let endsWith = ruleAttr.some(e => strEndsWith(runtimeAttr, e)); + let strEndsWith = ruleAttr.some(e => endsWith(runtimeAttr, e)); - log.debug(ENGINE_MATCHER_ENDS_WITH, [runtimeAttr, ruleAttr, endsWith]); + log.debug(ENGINE_MATCHER_ENDS_WITH, [runtimeAttr, ruleAttr, strEndsWith]); - return endsWith; + return strEndsWith; }; } diff --git a/src/evaluator/parser/index.ts b/src/evaluator/parser/index.ts index 8e53a321..25c1e7f1 100644 --- a/src/evaluator/parser/index.ts +++ b/src/evaluator/parser/index.ts @@ -31,7 +31,7 @@ export function parser(log: ILogger, conditions: ISplitCondition[], storage: ISt const matcher = matcherFactory(log, matcherDto, storage); // Evaluator function. - return (key: string, attributes: SplitIO.Attributes, splitEvaluator: ISplitEvaluator) => { + return (key: string, attributes: SplitIO.Attributes | undefined, splitEvaluator: ISplitEvaluator) => { const value = sanitizeValue(log, key, matcherDto, attributes); const result = value !== undefined && matcher ? matcher(value, splitEvaluator) : false; diff --git a/src/evaluator/types.ts b/src/evaluator/types.ts index ccab4db8..54078b5b 100644 --- a/src/evaluator/types.ts +++ b/src/evaluator/types.ts @@ -6,7 +6,7 @@ import { ILogger } from '../logger/types'; export interface IDependencyMatcherValue { key: SplitIO.SplitKey, - attributes: SplitIO.Attributes + attributes?: SplitIO.Attributes } export interface IMatcherDto { @@ -27,7 +27,7 @@ export interface IEvaluation { export type IEvaluationResult = IEvaluation & { treatment: string } -export type ISplitEvaluator = (log: ILogger, key: SplitIO.SplitKey, splitName: string, attributes: SplitIO.Attributes, storage: IStorageSync | IStorageAsync) => MaybeThenable +export type ISplitEvaluator = (log: ILogger, key: SplitIO.SplitKey, splitName: string, attributes: SplitIO.Attributes | undefined, storage: IStorageSync | IStorageAsync) => MaybeThenable export type IEvaluator = (key: SplitIO.SplitKey, seed: number, trafficAllocation?: number, trafficAllocationSeed?: number, attributes?: SplitIO.Attributes, splitEvaluator?: ISplitEvaluator) => MaybeThenable diff --git a/src/evaluator/value/index.ts b/src/evaluator/value/index.ts index 319e05e2..c564a68f 100644 --- a/src/evaluator/value/index.ts +++ b/src/evaluator/value/index.ts @@ -4,7 +4,7 @@ import { ILogger } from '../../logger/types'; import { sanitize } from './sanitize'; import { ENGINE_VALUE, ENGINE_VALUE_NO_ATTRIBUTES, ENGINE_VALUE_INVALID } from '../../logger/constants'; -function parseValue(log: ILogger, key: string, attributeName: string | null, attributes: SplitIO.Attributes) { +function parseValue(log: ILogger, key: string, attributeName: string | null, attributes?: SplitIO.Attributes) { let value = undefined; if (attributeName) { if (attributes) { @@ -23,7 +23,7 @@ function parseValue(log: ILogger, key: string, attributeName: string | null, att /** * Defines value to be matched (key / attribute). */ -export function sanitizeValue(log: ILogger, key: string, matcherDto: IMatcherDto, attributes: SplitIO.Attributes) { +export function sanitizeValue(log: ILogger, key: string, matcherDto: IMatcherDto, attributes?: SplitIO.Attributes) { const attributeName = matcherDto.attribute; const valueToMatch = parseValue(log, key, attributeName, attributes); const sanitizedValue = sanitize(log, matcherDto.type, valueToMatch, matcherDto.dataType, attributes); diff --git a/src/evaluator/value/sanitize.ts b/src/evaluator/value/sanitize.ts index 6a36a59d..d12de8ed 100644 --- a/src/evaluator/value/sanitize.ts +++ b/src/evaluator/value/sanitize.ts @@ -41,7 +41,7 @@ function sanitizeBoolean(val: any): boolean | undefined { return undefined; } -function dependencyProcessor(sanitizedValue: string, attributes: SplitIO.Attributes): IDependencyMatcherValue { +function dependencyProcessor(sanitizedValue: string, attributes?: SplitIO.Attributes): IDependencyMatcherValue { return { key: sanitizedValue, attributes @@ -69,7 +69,7 @@ function getProcessingFunction(matcherTypeID: number, dataType: string) { /** * Sanitize matcher value */ -export function sanitize(log: ILogger, matcherTypeID: number, value: string | number | boolean | Array | undefined, dataType: string, attributes: SplitIO.Attributes) { +export function sanitize(log: ILogger, matcherTypeID: number, value: string | number | boolean | Array | undefined, dataType: string, attributes?: SplitIO.Attributes) { const processor = getProcessingFunction(matcherTypeID, dataType); let sanitizedValue: string | number | boolean | Array | IDependencyMatcherValue | undefined; diff --git a/src/integrations/ga/GoogleAnalyticsToSplit.ts b/src/integrations/ga/GoogleAnalyticsToSplit.ts index 940447aa..95923713 100644 --- a/src/integrations/ga/GoogleAnalyticsToSplit.ts +++ b/src/integrations/ga/GoogleAnalyticsToSplit.ts @@ -1,11 +1,14 @@ -import { IIntegrationFactoryParams } from '../types'; +import { IIntegrationFactoryParams, IntegrationFactory } from '../types'; import { GaToSplit } from './GaToSplit'; import { GoogleAnalyticsToSplitOptions } from './types'; -export function GoogleAnalyticsToSplit(options: GoogleAnalyticsToSplitOptions) { +export function GoogleAnalyticsToSplit(options: GoogleAnalyticsToSplitOptions): IntegrationFactory { // GaToSplit integration factory - return (params: IIntegrationFactoryParams) => { + function GoogleAnalyticsToSplitFactory(params: IIntegrationFactoryParams) { return GaToSplit(options, params); - }; + } + + GoogleAnalyticsToSplitFactory.type = 'GOOGLE_ANALYTICS_TO_SPLIT'; + return GoogleAnalyticsToSplitFactory; } diff --git a/src/integrations/ga/SplitToGoogleAnalytics.ts b/src/integrations/ga/SplitToGoogleAnalytics.ts index 4ec71177..101df26f 100644 --- a/src/integrations/ga/SplitToGoogleAnalytics.ts +++ b/src/integrations/ga/SplitToGoogleAnalytics.ts @@ -1,11 +1,14 @@ -import { IIntegrationFactoryParams } from '../types'; +import { IIntegrationFactoryParams, IntegrationFactory } from '../types'; import { SplitToGa } from './SplitToGa'; import { SplitToGoogleAnalyticsOptions } from './types'; -export function SplitToGoogleAnalytics(options: SplitToGoogleAnalyticsOptions = {}) { +export function SplitToGoogleAnalytics(options: SplitToGoogleAnalyticsOptions = {}): IntegrationFactory { // SplitToGa integration factory - return (params: IIntegrationFactoryParams) => { + function SplitToGoogleAnalyticsFactory(params: IIntegrationFactoryParams) { return new SplitToGa(params.settings.log, options); - }; + } + + SplitToGoogleAnalyticsFactory.type = 'SPLIT_TO_GOOGLE_ANALYTICS'; + return SplitToGoogleAnalyticsFactory; } diff --git a/src/integrations/pluggable.ts b/src/integrations/pluggable.ts index aed6806c..df4ccd21 100644 --- a/src/integrations/pluggable.ts +++ b/src/integrations/pluggable.ts @@ -29,10 +29,10 @@ export function pluggableIntegrationsManagerFactory( // Exception safe methods: each integration module is responsable for handling errors return { - handleImpression: function (impressionData: SplitIO.ImpressionData) { + handleImpression(impressionData: SplitIO.ImpressionData) { listeners.forEach(listener => listener.queue({ type: SPLIT_IMPRESSION, payload: impressionData })); }, - handleEvent: function (eventData: SplitIO.EventData) { + handleEvent(eventData: SplitIO.EventData) { listeners.forEach(listener => listener.queue({ type: SPLIT_EVENT, payload: eventData })); } }; diff --git a/src/integrations/types.ts b/src/integrations/types.ts index 02e868f1..0c050879 100644 --- a/src/integrations/types.ts +++ b/src/integrations/types.ts @@ -12,3 +12,8 @@ export interface IIntegrationFactoryParams { storage: { events: IEventsCacheBase } settings: ISettings } + +export type IntegrationFactory = { + readonly type: string + (params: IIntegrationFactoryParams): IIntegration | void +} diff --git a/src/listeners/__tests__/browser.spec.ts b/src/listeners/__tests__/browser.spec.ts index 8af13e8b..5ec91eff 100644 --- a/src/listeners/__tests__/browser.spec.ts +++ b/src/listeners/__tests__/browser.spec.ts @@ -85,11 +85,12 @@ beforeAll(() => { }); }); -// clean mocks -afterEach(() => { +// clear mocks +beforeEach(() => { (global.window.addEventListener as jest.Mock).mockClear(); (global.window.removeEventListener as jest.Mock).mockClear(); if (global.window.navigator.sendBeacon) (global.window.navigator.sendBeacon as jest.Mock).mockClear(); + Object.values(fakeSplitApi).forEach(method => method.mockClear()); }); // delete mocks from global @@ -227,3 +228,34 @@ test('Browser JS listener / standalone mode / Impressions debug mode without sen // restore sendBeacon API global.navigator.sendBeacon = sendBeacon; }); + +test('Browser JS listener / standalone mode / user consent status', () => { + const syncManagerMock = {}; + const settings = { ...fullSettings }; + + // @ts-expect-error + const listener = new BrowserSignalListener(syncManagerMock, settings, fakeStorageOptimized as IStorageSync, fakeSplitApi); + + listener.start(); + + settings.userConsent = 'UNKNOWN'; + triggerUnloadEvent(); + settings.userConsent = 'DECLINED'; + triggerUnloadEvent(); + + // Unload event was triggered when user consent was unknown and declined. Thus sendBeacon and post services should not be called + expect(global.window.navigator.sendBeacon).toBeCalledTimes(0); + expect(fakeSplitApi.postTestImpressionsBulk).not.toBeCalled(); + expect(fakeSplitApi.postEventsBulk).not.toBeCalled(); + expect(fakeSplitApi.postTestImpressionsCount).not.toBeCalled(); + + settings.userConsent = 'GRANTED'; + triggerUnloadEvent(); + settings.userConsent = undefined; + triggerUnloadEvent(); + + // Unload event was triggered when user consent was granted and undefined. Thus sendBeacon should be called 6 times (3 times per event in optimized mode). + expect(global.window.navigator.sendBeacon).toBeCalledTimes(6); + + listener.stop(); +}); diff --git a/src/listeners/browser.ts b/src/listeners/browser.ts index 9705af2c..5bbd3496 100644 --- a/src/listeners/browser.ts +++ b/src/listeners/browser.ts @@ -11,6 +11,7 @@ import { OPTIMIZED, DEBUG } from '../utils/constants'; import { objectAssign } from '../utils/lang/objectAssign'; import { CLEANUP_REGISTERING, CLEANUP_DEREGISTERING } from '../logger/constants'; import { ISyncManager } from '../sync/types'; +import { isConsentGranted } from '../consent'; // 'unload' event is used instead of 'beforeunload', since 'unload' is not a cancelable event, so no other listeners can stop the event from occurring. const UNLOAD_DOM_EVENT = 'unload'; @@ -65,15 +66,18 @@ export class BrowserSignalListener implements ISignalListener { flushData() { if (!this.syncManager) return; // In consumer mode there is not sync manager and data to flush - const eventsUrl = this.settings.urls.events; - const extraMetadata = { - // sim stands for Sync/Split Impressions Mode - sim: this.settings.sync.impressionsMode === OPTIMIZED ? OPTIMIZED : DEBUG - }; + // Flush data if there is user consent + if (isConsentGranted(this.settings)) { + const eventsUrl = this.settings.urls.events; + const extraMetadata = { + // sim stands for Sync/Split Impressions Mode + sim: this.settings.sync.impressionsMode === OPTIMIZED ? OPTIMIZED : DEBUG + }; - this._flushData(eventsUrl + '/testImpressions/beacon', this.storage.impressions, this.serviceApi.postTestImpressionsBulk, this.fromImpressionsCollector, extraMetadata); - this._flushData(eventsUrl + '/events/beacon', this.storage.events, this.serviceApi.postEventsBulk); - if (this.storage.impressionCounts) this._flushData(eventsUrl + '/testImpressions/count/beacon', this.storage.impressionCounts, this.serviceApi.postTestImpressionsCount, fromImpressionCountsCollector); + this._flushData(eventsUrl + '/testImpressions/beacon', this.storage.impressions, this.serviceApi.postTestImpressionsBulk, this.fromImpressionsCollector, extraMetadata); + this._flushData(eventsUrl + '/events/beacon', this.storage.events, this.serviceApi.postEventsBulk); + if (this.storage.impressionCounts) this._flushData(eventsUrl + '/testImpressions/count/beacon', this.storage.impressionCounts, this.serviceApi.postTestImpressionsCount, fromImpressionCountsCollector); + } // Close streaming connection if (this.syncManager.pushManager) this.syncManager.pushManager.stop(); @@ -84,7 +88,7 @@ export class BrowserSignalListener implements ISignalListener { if (!cache.isEmpty()) { const dataPayload = fromCacheToPayload ? fromCacheToPayload(cache.state()) : cache.state(); if (!this._sendBeacon(url, dataPayload, extraMetadata)) { - postService(JSON.stringify(dataPayload)).catch(() => { }); // no-op just to catch a possible exceptions + postService(JSON.stringify(dataPayload)).catch(() => { }); // no-op just to catch a possible exception } cache.clear(); } diff --git a/src/logger/constants.ts b/src/logger/constants.ts index ab711cac..10b45254 100644 --- a/src/logger/constants.ts +++ b/src/logger/constants.ts @@ -61,13 +61,16 @@ export const STREAMING_RECONNECT = 111; export const STREAMING_CONNECTING = 112; export const STREAMING_DISABLED = 113; export const STREAMING_DISCONNECTING = 114; -export const SUBMITTERS_PUSH_FULL_EVENTS_QUEUE = 115; +export const SUBMITTERS_PUSH_FULL_QUEUE = 115; export const SUBMITTERS_PUSH = 116; export const SYNC_START_POLLING = 117; export const SYNC_CONTINUE_POLLING = 118; export const SYNC_STOP_POLLING = 119; export const EVENTS_TRACKER_SUCCESS = 120; export const IMPRESSIONS_TRACKER_SUCCESS = 121; +export const USER_CONSENT_UPDATED = 122; +export const USER_CONSENT_NOT_UPDATED = 123; +export const USER_CONSENT_INITIAL = 124; export const ENGINE_VALUE_INVALID = 200; export const ENGINE_VALUE_NO_ATTRIBUTES = 201; @@ -115,10 +118,11 @@ export const ERROR_INVALID_KEY_OBJECT = 317; export const ERROR_INVALID = 318; export const ERROR_EMPTY = 319; export const ERROR_EMPTY_ARRAY = 320; -export const ERROR_INVALID_IMPRESSIONS_MODE = 321; +export const ERROR_INVALID_CONFIG_PARAM = 321; export const ERROR_HTTP = 322; export const ERROR_LOCALHOST_MODULE_REQUIRED = 323; export const ERROR_STORAGE_INVALID = 324; +export const ERROR_NOT_BOOLEAN = 325; // Log prefixes (a.k.a. tags or categories) export const LOG_PREFIX_SETTINGS = 'settings'; diff --git a/src/logger/messages/debug.ts b/src/logger/messages/debug.ts index ed8281ed..c57ea6e0 100644 --- a/src/logger/messages/debug.ts +++ b/src/logger/messages/debug.ts @@ -31,9 +31,9 @@ export const codesDebug: [number, string][] = codesInfo.concat([ // SDK [c.CLEANUP_REGISTERING, c.LOG_PREFIX_CLEANUP + 'Registering cleanup handler %s'], [c.CLEANUP_DEREGISTERING, c.LOG_PREFIX_CLEANUP + 'Deregistering cleanup handler %s'], - [c.RETRIEVE_CLIENT_DEFAULT, ' Retrieving default SDK client.'], - [c.RETRIEVE_CLIENT_EXISTING, ' Retrieving existing SDK client.'], - [c.RETRIEVE_MANAGER, ' Retrieving manager instance.'], + [c.RETRIEVE_CLIENT_DEFAULT, 'Retrieving default SDK client.'], + [c.RETRIEVE_CLIENT_EXISTING, 'Retrieving existing SDK client.'], + [c.RETRIEVE_MANAGER, 'Retrieving manager instance.'], // synchronizer [c.SYNC_OFFLINE_DATA, c.LOG_PREFIX_SYNC_OFFLINE + 'Splits data: \n%s'], [c.SYNC_SPLITS_FETCH, c.LOG_PREFIX_SYNC_SPLITS + 'Spin up split update using since = %s'], diff --git a/src/logger/messages/error.ts b/src/logger/messages/error.ts index fee6c423..8ef9065b 100644 --- a/src/logger/messages/error.ts +++ b/src/logger/messages/error.ts @@ -5,7 +5,7 @@ export const codesError: [number, string][] = [ [c.ERROR_ENGINE_COMBINER_IFELSEIF, c.LOG_PREFIX_ENGINE_COMBINER + 'Invalid Split, no valid rules found'], // SDK [c.ERROR_LOGLEVEL_INVALID, 'logger: Invalid Log Level - No changes to the logs will be applied.'], - [c.ERROR_CLIENT_CANNOT_GET_READY, ' The SDK will not get ready. Reason: %s'], + [c.ERROR_CLIENT_CANNOT_GET_READY, 'The SDK will not get ready. Reason: %s'], [c.ERROR_IMPRESSIONS_TRACKER, c.LOG_PREFIX_IMPRESSIONS_TRACKER + 'Could not store impressions bulk with %s impression(s). Error: %s'], [c.ERROR_IMPRESSIONS_LISTENER, c.LOG_PREFIX_IMPRESSIONS_TRACKER + 'Impression listener logImpression method threw: %s.'], [c.ERROR_EVENTS_TRACKER, c.LOG_PREFIX_EVENTS_TRACKER + 'Failed to queue %s'], @@ -13,7 +13,7 @@ export const codesError: [number, string][] = [ [c.ERROR_SYNC_OFFLINE_LOADING, c.LOG_PREFIX_SYNC_OFFLINE + 'There was an issue loading the mock Splits data, no changes will be applied to the current cache. %s'], [c.ERROR_STREAMING_SSE, c.LOG_PREFIX_SYNC_STREAMING + 'Failed to connect or error on streaming connection, with error message: %s'], [c.ERROR_STREAMING_AUTH, c.LOG_PREFIX_SYNC_STREAMING + 'Failed to authenticate for streaming. Error: %s.'], - [c.ERROR_HTTP, ' Response status is not OK. Status: %s. URL: %s. Message: %s'], + [c.ERROR_HTTP, 'Response status is not OK. Status: %s. URL: %s. Message: %s'], // client status [c.ERROR_CLIENT_LISTENER, 'A listener was added for %s on the SDK, which has already fired and won\'t be emitted again. The callback won\'t be executed.'], [c.ERROR_CLIENT_DESTROYED, '%s: Client has already been destroyed - no calls possible.'], @@ -28,8 +28,9 @@ export const codesError: [number, string][] = [ [c.ERROR_INVALID, '%s: you passed an invalid %s. It must be a non-empty string.'], [c.ERROR_EMPTY, '%s: you passed an empty %s. It must be a non-empty string.'], [c.ERROR_EMPTY_ARRAY, '%s: %s must be a non-empty array.'], + [c.ERROR_NOT_BOOLEAN, '%s: provided param must be a boolean value.'], // initialization / settings validation - [c.ERROR_INVALID_IMPRESSIONS_MODE, c.LOG_PREFIX_SETTINGS + ': you passed an invalid "impressionsMode". It should be one of the following values: %s. Defaulting to "%s" mode.'], + [c.ERROR_INVALID_CONFIG_PARAM, c.LOG_PREFIX_SETTINGS + ': you passed an invalid "%s" config param. It should be one of the following values: %s. Defaulting to "%s".'], [c.ERROR_LOCALHOST_MODULE_REQUIRED, c.LOG_PREFIX_SETTINGS + ': an invalid value was received for "sync.localhostMode" config. A valid entity should be provided for localhost mode.'], [c.ERROR_STORAGE_INVALID, c.LOG_PREFIX_SETTINGS+': The provided storage is invalid.%s Fallbacking into default MEMORY storage'], ]; diff --git a/src/logger/messages/info.ts b/src/logger/messages/info.ts index 42838345..96703540 100644 --- a/src/logger/messages/info.ts +++ b/src/logger/messages/info.ts @@ -10,24 +10,27 @@ export const codesInfo: [number, string][] = codesWarn.concat([ // SDK [c.IMPRESSION, c.LOG_PREFIX_IMPRESSIONS_TRACKER +'Split: %s. Key: %s. Evaluation: %s. Label: %s'], [c.IMPRESSION_QUEUEING, c.LOG_PREFIX_IMPRESSIONS_TRACKER +'Queueing corresponding impression.'], - [c.NEW_SHARED_CLIENT, ' New shared client instance created.'], - [c.NEW_FACTORY, ' New Split SDK instance created.'], + [c.NEW_SHARED_CLIENT, 'New shared client instance created.'], + [c.NEW_FACTORY, 'New Split SDK instance created.'], [c.EVENTS_TRACKER_SUCCESS, c.LOG_PREFIX_EVENTS_TRACKER + 'Successfully queued %s'], [c.IMPRESSIONS_TRACKER_SUCCESS, c.LOG_PREFIX_IMPRESSIONS_TRACKER + 'Successfully stored %s impression(s).'], + [c.USER_CONSENT_UPDATED, 'setUserConsent: consent status changed from %s to %s.'], + [c.USER_CONSENT_NOT_UPDATED, 'setUserConsent: call had no effect because it was the current consent status (%s).'], + [c.USER_CONSENT_INITIAL, 'Starting the SDK with %s user consent. No data will be sent.'], // synchronizer [c.POLLING_SMART_PAUSING, c.LOG_PREFIX_SYNC_POLLING + 'Turning segments data polling %s.'], [c.POLLING_START, c.LOG_PREFIX_SYNC_POLLING + 'Starting polling'], [c.POLLING_STOP, c.LOG_PREFIX_SYNC_POLLING + 'Stopping polling'], [c.SYNC_SPLITS_FETCH_RETRY, c.LOG_PREFIX_SYNC_SPLITS + 'Retrying download of splits #%s. Reason: %s'], - [c.SUBMITTERS_PUSH_FULL_EVENTS_QUEUE, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Flushing full events queue and reseting timer.'], + [c.SUBMITTERS_PUSH_FULL_QUEUE, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Flushing full %s queue and reseting timer.'], [c.SUBMITTERS_PUSH, c.LOG_PREFIX_SYNC_SUBMITTERS + 'Pushing %s %s.'], [c.STREAMING_REFRESH_TOKEN, c.LOG_PREFIX_SYNC_STREAMING + 'Refreshing streaming token in %s seconds, and connecting streaming in %s seconds.'], - [c.STREAMING_RECONNECT, c.LOG_PREFIX_SYNC_STREAMING + 'Attempting to reconnect in %s seconds.'], - [c.STREAMING_CONNECTING, c.LOG_PREFIX_SYNC_STREAMING + 'Connecting to streaming.'], + [c.STREAMING_RECONNECT, c.LOG_PREFIX_SYNC_STREAMING + 'Attempting to reconnect streaming in %s seconds.'], + [c.STREAMING_CONNECTING, c.LOG_PREFIX_SYNC_STREAMING + 'Connecting streaming.'], [c.STREAMING_DISABLED, c.LOG_PREFIX_SYNC_STREAMING + 'Streaming is disabled for given Api key. Switching to polling mode.'], - [c.STREAMING_DISCONNECTING, c.LOG_PREFIX_SYNC_STREAMING + 'Disconnecting from streaming.'], + [c.STREAMING_DISCONNECTING, c.LOG_PREFIX_SYNC_STREAMING + 'Disconnecting streaming.'], [c.SYNC_START_POLLING, c.LOG_PREFIX_SYNC_MANAGER + 'Streaming not available. Starting polling.'], [c.SYNC_CONTINUE_POLLING, c.LOG_PREFIX_SYNC_MANAGER + 'Streaming couldn\'t connect. Continue polling.'], - [c.SYNC_STOP_POLLING, c.LOG_PREFIX_SYNC_MANAGER + 'Streaming (re)connected. Syncing and stopping polling.'], + [c.SYNC_STOP_POLLING, c.LOG_PREFIX_SYNC_MANAGER + 'Streaming connected. Syncing and stopping polling.'], ]); diff --git a/src/logger/types.ts b/src/logger/types.ts index 05043be4..79ec1b07 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -9,11 +9,15 @@ export interface ILoggerOptions { export interface ILogger { setLogLevel(logLevel: LogLevel): void + debug(msg: any): void debug(msg: string | number, args?: any[]): void + info(msg: any): void info(msg: string | number, args?: any[]): void + warn(msg: any): void warn(msg: string | number, args?: any[]): void + error(msg: any): void error(msg: string | number, args?: any[]): void } diff --git a/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts b/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts index bb6c0df7..f5c49f96 100644 --- a/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts +++ b/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts @@ -32,10 +32,12 @@ test('ATTRIBUTES DECORATION / storage', () => { expect(client.getAttribute('attributeName1')).toEqual(undefined); // It should throw undefined expect(client.getAttribute('attributeName2')).toEqual('newAttributeValue2'); // It should be equal - client.setAttributes({ + expect(client.setAttributes({ 'attributeName3': 'attributeValue3', 'attributeName4': 'attributeValue4' - }); + })).toEqual(true); // @ts-ignore + expect(client.setAttributes(undefined)).toEqual(false); // @ts-ignore + expect(client.setAttributes(null)).toEqual(false); expect(client.getAttributes()).toEqual({ attributeName2: 'newAttributeValue2', attributeName3: 'attributeValue3', attributeName4: 'attributeValue4' }); // It should be equal @@ -48,7 +50,11 @@ test('ATTRIBUTES DECORATION / storage', () => { describe('ATTRIBUTES DECORATION / validation', () => { - test('Should return true if it is a valid attributes map without logging any errors', () => { + beforeEach(() => { + loggerMock.mockClear(); + }); + + test('Should return true if it is a valid attributes map without logging any errors and warnings', () => { const validAttributes = { amIvalid: 'yes', 'are_you_sure': true, howMuch: 10, 'spell': ['1', '0'] }; expect(client.setAttributes(validAttributes)).toEqual(true); // It should return true if it is valid. @@ -63,6 +69,8 @@ describe('ATTRIBUTES DECORATION / validation', () => { expect(Object.keys(client.getAttributes()).length).toEqual(0); // It should be zero after clearing attributes + expect(loggerMock.error).not.toBeCalled(); // no error logs + expect(loggerMock.warn).not.toBeCalled(); // no warning logs }); test('Should return false if it is an invalid attributes map', () => { @@ -82,12 +90,17 @@ describe('ATTRIBUTES DECORATION / validation', () => { '': 'attributeValue' }; - expect(client.setAttributes(attributes)).toEqual(false); // It should be invalid if the attribute key is not a string + expect(client.setAttributes(attributes)).toEqual(false); // @ts-ignore // It should be invalid if the attribute key is not a string + expect(client.setAttributes(undefined)).toEqual(false); // @ts-ignore // It should be invalid if the attributes param is nullish. Doesn't log an error + expect(client.setAttributes(null)).toEqual(false); // @ts-ignore // It should be invalid if the attributes param is nullish. Doesn't log an error + expect(client.setAttributes('invalid')).toEqual(false); // It should be invalid if the attributes param is not an object expect(Object.keys(client.getAttributes()).length).toEqual(0); // It should be zero after trying to add an invalid attribute expect(client.clearAttributes()).toEqual(true); + expect(loggerMock.error).toBeCalledTimes(1); // error logs + expect(loggerMock.warn).toBeCalledTimes(5); // warning logs }); test('Should return true if attributes map is valid', () => { @@ -114,6 +127,8 @@ describe('ATTRIBUTES DECORATION / validation', () => { expect(client.clearAttributes()).toEqual(true); + expect(loggerMock.error).toBeCalledTimes(0); // no error logs + expect(loggerMock.warn).toBeCalledTimes(0); // no warning logs }); }); diff --git a/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts b/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts index b5b6b21e..7eabeb7c 100644 --- a/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts +++ b/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts @@ -2,10 +2,6 @@ import { sdkClientMethodCSFactory as sdkClientMethodCSWithTTFactory } from '../s import { sdkClientMethodCSFactory } from '../sdkClientMethodCS'; import { assertClientApi } from './testUtils'; -/** Mocks */ -import * as clientCS from '../clientCS'; -const clientCSDecoratorSpy = jest.spyOn(clientCS, 'clientCSDecorator'); - import { settingsWithKey, settingsWithKeyAndTT, settingsWithKeyObject } from '../../utils/settingsValidation/__tests__/settings.mocks'; const partialStorages: { destroy: jest.Mock }[] = []; @@ -220,31 +216,6 @@ describe('sdkClientMethodCSFactory', () => { if (!ignoresTT) expect(() => sdkClientMethod('valid-key', ['invalid-TT'])).toThrow('Shared Client needs a valid traffic type or no traffic type at all.'); }); - test.each(testTargets)('invalid key/TT binds a false key/TT in the default client', (sdkClientMethodCSFactory, ignoresTT) => { - const paramsWithInvalidKeyAndTT = { - ...params, - settings: { - ...params.settings, - core: { - key: true, // invalid key - trafficType: '' // invalid TT - } - } - }; - - (clientCSDecoratorSpy as jest.Mock).mockClear(); - // @ts-expect-error - const sdkClientMethod = sdkClientMethodCSFactory(paramsWithInvalidKeyAndTT); - - // calling the function should return a client instance - const client = sdkClientMethod(); - assertClientApi(client, params.sdkReadinessManager.sdkStatus); - - // but with false as binded key and TT - if (ignoresTT) expect(clientCSDecoratorSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), false); - else expect(clientCSDecoratorSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), false, false); - }); - test.each(testTargets)('attributes binding - main client', (sdkClientMethodCSFactory) => { // @ts-expect-error const sdkClientMethod = sdkClientMethodCSFactory(params); diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index a572047e..44501624 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -5,18 +5,18 @@ import { validateSplitExistance } from '../utils/inputValidation/splitExistance' import { validateTrafficTypeExistance } from '../utils/inputValidation/trafficTypeExistance'; import { SDK_NOT_READY } from '../utils/labels'; import { CONTROL } from '../utils/constants'; -import { IClientFactoryParams } from './types'; import { IEvaluationResult } from '../evaluator/types'; import { SplitIO, ImpressionDTO } from '../types'; import { IMPRESSION, IMPRESSION_QUEUEING } from '../logger/constants'; - +import { ISdkFactoryContext } from '../sdkFactory/types'; /** * Creator of base client with getTreatments and track methods. */ // @TODO missing time tracking to collect telemetry -export function clientFactory(params: IClientFactoryParams): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings: { log, mode }, impressionsTracker, eventTracker } = params; +export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient { + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker } = params; + const { log, mode } = settings; function getTreatment(key: SplitIO.SplitKey, splitName: string, attributes: SplitIO.Attributes | undefined, withConfig = false) { const wrapUp = (evaluationResult: IEvaluationResult) => { @@ -123,6 +123,7 @@ export function clientFactory(params: IClientFactoryParams): SplitIO.IClient | S getTreatmentWithConfig, getTreatments, getTreatmentsWithConfig, - track + track, + isClientSide: false } as SplitIO.IClient | SplitIO.IAsyncClient; } diff --git a/src/sdkClient/clientCS.ts b/src/sdkClient/clientCS.ts index 8cf0d6a2..c66d1e04 100644 --- a/src/sdkClient/clientCS.ts +++ b/src/sdkClient/clientCS.ts @@ -23,6 +23,8 @@ export function clientCSDecorator(log: ILogger, client: SplitIO.IClient, key: Sp getTreatmentsWithConfig: clientCS.getTreatmentsWithConfig.bind(clientCS, key), // Key is bound to the `track` method. Same thing happens with trafficType but only if provided - track: trafficType ? clientCS.track.bind(clientCS, key, trafficType) : clientCS.track.bind(clientCS, key) + track: trafficType ? clientCS.track.bind(clientCS, key, trafficType) : clientCS.track.bind(clientCS, key), + + isClientSide: true }) as SplitIO.ICsClient; } diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index df0eaebc..de9f3594 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -15,14 +15,17 @@ import { startsWith } from '../utils/lang'; import { CONTROL, CONTROL_WITH_CONFIG } from '../utils/constants'; import { IReadinessManager } from '../readiness/types'; import { MaybeThenable } from '../dtos/types'; -import { SplitIO } from '../types'; -import { ILogger } from '../logger/types'; +import { ISettings, SplitIO } from '../types'; +import { isStorageSync } from '../trackers/impressionObserver/utils'; /** * Decorator that validates the input before actually executing the client methods. * We should "guard" the client here, while not polluting the "real" implementation of those methods. */ -export function clientInputValidationDecorator(log: ILogger, client: TClient, readinessManager: IReadinessManager, isStorageSync = false): TClient { +export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager): TClient { + + const log = settings.log; + const isSync = isStorageSync(settings); /** * Avoid repeating this validations code @@ -47,8 +50,7 @@ export function clientInputValidationDecorator(value: T): MaybeThenable { - if (isStorageSync) return value; - return Promise.resolve(value); + return isSync ? value : Promise.resolve(value); } function getTreatment(maybeKey: SplitIO.SplitKey, maybeSplit: string, maybeAttributes?: SplitIO.Attributes) { @@ -108,8 +110,7 @@ export function clientInputValidationDecorator SplitIO.IClient | SplitIO.IAsyncClient { +export function sdkClientMethodFactory(params: ISdkFactoryContext): () => SplitIO.IClient | SplitIO.IAsyncClient { const log = params.settings.log; const clientInstance = sdkClientFactory(params); diff --git a/src/sdkClient/sdkClientMethodCS.ts b/src/sdkClient/sdkClientMethodCS.ts index 2abd7a20..284c59ae 100644 --- a/src/sdkClient/sdkClientMethodCS.ts +++ b/src/sdkClient/sdkClientMethodCS.ts @@ -1,5 +1,4 @@ import { clientCSDecorator } from './clientCS'; -import { ISdkClientFactoryParams } from './types'; import { SplitIO } from '../types'; import { validateKey } from '../utils/inputValidation/key'; import { getMatching, keyParser } from '../utils/key'; @@ -8,6 +7,7 @@ import { ISyncManagerCS } from '../sync/types'; import { objectAssign } from '../utils/lang/objectAssign'; import { RETRIEVE_CLIENT_DEFAULT, NEW_SHARED_CLIENT, RETRIEVE_CLIENT_EXISTING } from '../logger/constants'; import { SDK_SEGMENTS_ARRIVED } from '../readiness/constants'; +import { ISdkFactoryContext } from '../sdkFactory/types'; function buildInstanceId(key: SplitIO.SplitKey) { // @ts-ignore @@ -20,18 +20,13 @@ const method = 'Client instantiation'; * Factory of client method for the client-side API variant where TT is ignored and thus * clients don't have a binded TT for the track method. */ -export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key?: SplitIO.SplitKey) => SplitIO.ICsClient { +export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: SplitIO.SplitKey) => SplitIO.ICsClient { const { storage, syncManager, sdkReadinessManager, settings: { core: { key }, startup: { readyTimeout }, log } } = params; - // Keeping similar behaviour as in the isomorphic JS SDK: if settings key is invalid, - // `false` value is used as binded key of the default client, but trafficType is ignored - // @TODO handle as a non-recoverable error - const validKey = validateKey(log, key, method); - const mainClientInstance = clientCSDecorator( log, - sdkClientFactory(params) as SplitIO.IClient, // @ts-ignore - validKey + sdkClientFactory(params) as SplitIO.IClient, + key ); const parsedDefaultKey = keyParser(key); @@ -81,8 +76,7 @@ export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key? storage: sharedStorage || storage, syncManager: sharedSyncManager, signalListener: undefined, // only the main client "destroy" method stops the signal listener - sharedClient: true - })) as SplitIO.IClient, + }), true) as SplitIO.IClient, validKey ); diff --git a/src/sdkClient/sdkClientMethodCSWithTT.ts b/src/sdkClient/sdkClientMethodCSWithTT.ts index 7c5427e2..7abb36d8 100644 --- a/src/sdkClient/sdkClientMethodCSWithTT.ts +++ b/src/sdkClient/sdkClientMethodCSWithTT.ts @@ -1,5 +1,4 @@ import { clientCSDecorator } from './clientCS'; -import { ISdkClientFactoryParams } from './types'; import { SplitIO } from '../types'; import { validateKey } from '../utils/inputValidation/key'; import { validateTrafficType } from '../utils/inputValidation/trafficType'; @@ -9,6 +8,7 @@ import { ISyncManagerCS } from '../sync/types'; import { objectAssign } from '../utils/lang/objectAssign'; import { RETRIEVE_CLIENT_DEFAULT, NEW_SHARED_CLIENT, RETRIEVE_CLIENT_EXISTING } from '../logger/constants'; import { SDK_SEGMENTS_ARRIVED } from '../readiness/constants'; +import { ISdkFactoryContext } from '../sdkFactory/types'; function buildInstanceId(key: SplitIO.SplitKey, trafficType?: string) { // @ts-ignore @@ -22,23 +22,14 @@ const method = 'Client instantiation'; * where clients can have a binded TT for the track method, which is provided via the settings * (default client) or the client method (shared clients). */ -export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key?: SplitIO.SplitKey, trafficType?: string) => SplitIO.ICsClient { +export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: SplitIO.SplitKey, trafficType?: string) => SplitIO.ICsClient { const { storage, syncManager, sdkReadinessManager, settings: { core: { key, trafficType }, startup: { readyTimeout }, log } } = params; - // Keeping the behaviour as in the isomorphic JS SDK: if settings key or TT are invalid, - // `false` value is used as binded key/TT of the default client, which leads to several issues. - // @TODO update when supporting non-recoverable errors - const validKey = validateKey(log, key, method); - let validTrafficType; - if (trafficType !== undefined) { - validTrafficType = validateTrafficType(log, trafficType, method); - } - const mainClientInstance = clientCSDecorator( log, - sdkClientFactory(params) as SplitIO.IClient, // @ts-ignore - validKey, - validTrafficType + sdkClientFactory(params) as SplitIO.IClient, + key, + trafficType ); const parsedDefaultKey = keyParser(key); @@ -95,8 +86,7 @@ export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key? storage: sharedStorage || storage, syncManager: sharedSyncManager, signalListener: undefined, // only the main client "destroy" method stops the signal listener - sharedClient: true - })) as SplitIO.IClient, + }), true) as SplitIO.IClient, validKey, validTrafficType ); diff --git a/src/sdkClient/types.ts b/src/sdkClient/types.ts deleted file mode 100644 index d5e3b3b8..00000000 --- a/src/sdkClient/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ISignalListener } from '../listeners/types'; -import { ISdkReadinessManager } from '../readiness/types'; -import { IStorageAsync, IStorageSync } from '../storages/types'; -import { ISyncManager } from '../sync/types'; -import { IEventTracker, IImpressionsTracker } from '../trackers/types'; -import { ISettings } from '../types'; - -export interface IClientFactoryParams { - storage: IStorageSync | IStorageAsync, - sdkReadinessManager: ISdkReadinessManager, - settings: ISettings - impressionsTracker: IImpressionsTracker, - eventTracker: IEventTracker, - // @TODO add time tracker and metricCollectors (a.k.a metricTracker)? -} - -export interface ISdkClientFactoryParams extends IClientFactoryParams { - signalListener?: ISignalListener - syncManager?: ISyncManager, - sharedClient?: boolean -} diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index 61433a5e..e28bd1e2 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -12,14 +12,15 @@ import { createLoggerAPI } from '../logger/sdkLogger'; import { NEW_FACTORY, RETRIEVE_MANAGER } from '../logger/constants'; import { metadataBuilder } from '../storages/metadataBuilder'; import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../readiness/constants'; +import { objectAssign } from '../utils/lang/objectAssign'; /** * Modular SDK factory */ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO.ISDK | SplitIO.IAsyncSDK { - const { settings, platform, storageFactory, splitApiFactory, - syncManagerFactory, SignalListener, impressionsObserverFactory, impressionListener, + const { settings, platform, storageFactory, splitApiFactory, extraProps, + syncManagerFactory, SignalListener, impressionsObserverFactory, integrationsManagerFactory, sdkManagerFactory, sdkClientMethodFactory } = params; const log = settings.log; @@ -33,6 +34,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO. // @TODO consider passing the settings object, so that each storage access only what it needs const storageFactoryParams: IStorageFactoryParams = { + impressionsQueueSize: settings.scheduler.impressionsQueueSize, eventsQueueSize: settings.scheduler.eventsQueueSize, optimize: shouldBeOptimized(settings), @@ -72,14 +74,15 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO. // trackers const observer = impressionsObserverFactory && impressionsObserverFactory(); - const impressionsTracker = impressionsTrackerFactory(log, storage.impressions, settings, impressionListener, integrationsManager, observer, storage.impressionCounts); - const eventTracker = eventTrackerFactory(log, storage.events, integrationsManager); + const impressionsTracker = impressionsTrackerFactory(settings, storage.impressions, integrationsManager, observer, storage.impressionCounts); + const eventTracker = eventTrackerFactory(settings, storage.events, integrationsManager); // signal listener const signalListener = SignalListener && new SignalListener(syncManager, settings, storage, splitApi); // Sdk client and manager - const clientMethod = sdkClientMethodFactory({ eventTracker, impressionsTracker, sdkReadinessManager, settings, storage, syncManager, signalListener }); + const ctx = { eventTracker, impressionsTracker, sdkReadinessManager, settings, storage, syncManager, signalListener }; + const clientMethod = sdkClientMethodFactory(ctx); const managerInstance = sdkManagerFactory(log, storage.splits, sdkReadinessManager); syncManager && syncManager.start(); @@ -87,12 +90,12 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO. log.info(NEW_FACTORY); - return { + // @ts-ignore + return objectAssign({ // Split evaluation and event tracking engine client: clientMethod, // Manager API to explore available information - // @ts-ignore manager() { log.debug(RETRIEVE_MANAGER); return managerInstance; @@ -102,5 +105,5 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ICsSDK | SplitIO. Logger: createLoggerAPI(settings.log), settings, - }; + }, extraProps && extraProps(ctx)); } diff --git a/src/sdkFactory/types.ts b/src/sdkFactory/types.ts index d907d2d0..21968c46 100644 --- a/src/sdkFactory/types.ts +++ b/src/sdkFactory/types.ts @@ -2,13 +2,23 @@ import { IIntegrationManager, IIntegrationFactoryParams } from '../integrations/ import { ISignalListener } from '../listeners/types'; import { ILogger } from '../logger/types'; import { ISdkReadinessManager } from '../readiness/types'; -import { ISdkClientFactoryParams } from '../sdkClient/types'; import { IFetch, ISplitApi, IEventSourceConstructor } from '../services/types'; import { IStorageAsync, IStorageSync, ISplitsCacheSync, ISplitsCacheAsync, IStorageFactoryParams } from '../storages/types'; import { ISyncManager, ISyncManagerFactoryParams } from '../sync/types'; import { IImpressionObserver } from '../trackers/impressionObserver/types'; +import { IImpressionsTracker, IEventTracker } from '../trackers/types'; import { SplitIO, ISettings, IEventEmitter } from '../types'; +export interface ISdkFactoryContext { + storage: IStorageSync | IStorageAsync, + sdkReadinessManager: ISdkReadinessManager, + settings: ISettings + impressionsTracker: IImpressionsTracker, + eventTracker: IEventTracker, + signalListener?: ISignalListener + syncManager?: ISyncManager, +} + /** * Environment related dependencies. * These getters are called a fixed number of times per factory instantiation. @@ -53,7 +63,7 @@ export interface ISdkFactoryParams { // Sdk client method factory (ISDK::client method). // It Allows to distinguish SDK clients with the client-side API (`ICsSDK`) or server-side API (`ISDK` or `IAsyncSDK`). - sdkClientMethodFactory: (params: ISdkClientFactoryParams) => ({ (): SplitIO.ICsClient; (key: SplitIO.SplitKey, trafficType?: string | undefined): SplitIO.ICsClient; } | (() => SplitIO.IClient) | (() => SplitIO.IAsyncClient)) + sdkClientMethodFactory: (params: ISdkFactoryContext) => ({ (): SplitIO.ICsClient; (key: SplitIO.SplitKey, trafficType?: string | undefined): SplitIO.ICsClient; } | (() => SplitIO.IClient) | (() => SplitIO.IAsyncClient)) // Optional signal listener constructor. Used to handle special app states, like shutdown, app paused or resumed. // Pass only if `syncManager` (used by Node listener) and `splitApi` (used by Browser listener) are passed. @@ -64,10 +74,11 @@ export interface ISdkFactoryParams { serviceApi: ISplitApi | undefined) => ISignalListener, // Used by BrowserSignalListener // @TODO review impressionListener and integrations interfaces. What about handling impressionListener as an integration ? - impressionListener?: SplitIO.IImpressionListener, integrationsManagerFactory?: (params: IIntegrationFactoryParams) => IIntegrationManager | undefined, // Impression observer factory. If provided, will be used for impressions dedupe impressionsObserverFactory?: () => IImpressionObserver + // Optional function to assign additional properties to the factory instance + extraProps?: (params: ISdkFactoryContext) => object } diff --git a/src/services/splitHttpClient.ts b/src/services/splitHttpClient.ts index ee031149..d9ec90f7 100644 --- a/src/services/splitHttpClient.ts +++ b/src/services/splitHttpClient.ts @@ -49,7 +49,7 @@ export function splitHttpClientFactory(settings: Pick { - const resp = error.response; + const resp = error && error.response; let msg = ''; if (resp) { // An HTTP error diff --git a/src/storages/KeyBuilder.ts b/src/storages/KeyBuilder.ts index 080ed3d9..3c54d403 100644 --- a/src/storages/KeyBuilder.ts +++ b/src/storages/KeyBuilder.ts @@ -1,15 +1,11 @@ -import { endsWith, startsWith } from '../utils/lang'; +import { startsWith } from '../utils/lang'; const everythingAtTheEnd = /[^.]+$/; const DEFAULT_PREFIX = 'SPLITIO'; export function validatePrefix(prefix: unknown) { - return prefix && typeof prefix === 'string' ? - endsWith(prefix, '.' + DEFAULT_PREFIX) ? - prefix : // suffix already appended - prefix + '.' + DEFAULT_PREFIX : // append suffix - DEFAULT_PREFIX; // use default prefix if none is provided + return prefix ? prefix + '.SPLITIO' : 'SPLITIO'; } export class KeyBuilder { diff --git a/src/storages/KeyBuilderCS.ts b/src/storages/KeyBuilderCS.ts index d4f9e902..58c19fbb 100644 --- a/src/storages/KeyBuilderCS.ts +++ b/src/storages/KeyBuilderCS.ts @@ -16,10 +16,22 @@ export class KeyBuilderCS extends KeyBuilder { * @override */ buildSegmentNameKey(segmentName: string) { - return `${this.matchingKey}.${this.prefix}.segment.${segmentName}`; + return `${this.prefix}.${this.matchingKey}.segment.${segmentName}`; } extractSegmentName(builtSegmentKeyName: string) { + const prefix = `${this.prefix}.${this.matchingKey}.segment.`; + + if (startsWith(builtSegmentKeyName, prefix)) + return builtSegmentKeyName.substr(prefix.length); + } + + // @BREAKING: The key used to start with the matching key instead of the prefix, this was changed on version 10.17.3 + buildOldSegmentNameKey(segmentName: string) { + return `${this.matchingKey}.${this.prefix}.segment.${segmentName}`; + } + // @BREAKING: The key used to start with the matching key instead of the prefix, this was changed on version 10.17.3 + extractOldSegmentKey(builtSegmentKeyName: string) { const prefix = `${this.matchingKey}.${this.prefix}.segment.`; if (startsWith(builtSegmentKeyName, prefix)) diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 7bc6690a..627d1fcf 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -67,9 +67,29 @@ export class MySegmentsCacheInLocal extends AbstractSegmentsCacheSync { // Scan current values from localStorage const storedSegmentNames = Object.keys(localStorage).reduce((accum, key) => { - const name = this.keys.extractSegmentName(key); - - if (name) accum.push(name); + let segmentName = this.keys.extractSegmentName(key); + + if (segmentName) { + accum.push(segmentName); + } else { + // @BREAKING: This is only to clean up "old" keys. Remove this whole else code block. + segmentName = this.keys.extractOldSegmentKey(key); + + if (segmentName) { // this was an old segment key, let's clean up. + const newSegmentKey = this.keys.buildSegmentNameKey(segmentName); + try { + // If the new format key is not there, create it. + if (!localStorage.getItem(newSegmentKey) && names.indexOf(segmentName) > -1) { + localStorage.setItem(newSegmentKey, DEFINED); + // we are migrating a segment, let's track it. + accum.push(segmentName); + } + localStorage.removeItem(key); // we migrated the current key, let's delete it. + } catch (e) { + this.log.error(e); + } + } + } return accum; }, [] as string[]); diff --git a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts index 36072550..67c9efcc 100644 --- a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts @@ -17,3 +17,35 @@ test('SEGMENT CACHE / in LocalStorage', () => { expect(cache.isInSegment('mocked-segment') === false).toBe(true); }); + +// @BREAKING: REMOVE when removing this backwards compatibility. +test('SEGMENT CACHE / in LocalStorage migration for mysegments keys', () => { + + const keys = new KeyBuilderCS('LS_BC_test.SPLITIO', 'test_nico'); + const cache = new MySegmentsCacheInLocal(loggerMock, keys); + + const oldKey1 = 'test_nico.LS_BC_test.SPLITIO.segment.segment1'; + const oldKey2 = 'test_nico.LS_BC_test.SPLITIO.segment.segment2'; + const newKey1 = keys.buildSegmentNameKey('segment1'); + const newKey2 = keys.buildSegmentNameKey('segment2'); + + cache.clear(); // cleanup before starting. + + // Not adding a full suite for LS keys now, testing here + expect(oldKey1).toBe(keys.buildOldSegmentNameKey('segment1')); + expect('segment1').toBe(keys.extractOldSegmentKey(oldKey1)); + + // add two segments, one we don't want to send on reset, should only be cleared, other one will be migrated. + localStorage.setItem(oldKey1, '1'); + localStorage.setItem(oldKey2, '1'); + expect(localStorage.getItem(newKey1)).toBe(null); // control assertion + + cache.resetSegments(['segment1']); + + expect(localStorage.getItem(newKey1)).toBe('1'); // The segment key for segment1, as is part of the new list, should be migrated. + expect(localStorage.getItem(newKey2)).toBe(null); // The segment key for segment2 should not be migrated. + expect(localStorage.getItem(oldKey1)).toBe(null); // Old keys are removed. + expect(localStorage.getItem(oldKey2)).toBe(null); // Old keys are removed. + + cache.clear(); +}); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 0d35341b..68604e30 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -40,7 +40,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn return { splits: new SplitsCacheInLocal(log, keys, expirationTimestamp, params.splitFiltersValidation), segments: new MySegmentsCacheInLocal(log, keys), - impressions: new ImpressionsCacheInMemory(), + impressions: new ImpressionsCacheInMemory(params.impressionsQueueSize), impressionCounts: params.optimize ? new ImpressionCountsCacheInMemory() : undefined, events: new EventsCacheInMemory(params.eventsQueueSize), diff --git a/src/storages/inMemory/ImpressionsCacheInMemory.ts b/src/storages/inMemory/ImpressionsCacheInMemory.ts index dcbf6b7a..57621c71 100644 --- a/src/storages/inMemory/ImpressionsCacheInMemory.ts +++ b/src/storages/inMemory/ImpressionsCacheInMemory.ts @@ -3,13 +3,34 @@ import { ImpressionDTO } from '../../types'; export class ImpressionsCacheInMemory implements IImpressionsCacheSync { - private queue: ImpressionDTO[] = []; + private onFullQueue?: () => void; + private readonly maxQueue: number; + private queue: ImpressionDTO[]; + + /** + * + * @param impressionsQueueSize number of queued impressions to call onFullQueueCb. + * Default value is 0, that means no maximum value, in case we want to avoid this being triggered. + */ + constructor(impressionsQueueSize: number = 0) { + this.maxQueue = impressionsQueueSize; + this.queue = []; + } + + setOnFullQueueCb(cb: () => void) { + this.onFullQueue = cb; + } /** * Store impressions in sequential order */ track(data: ImpressionDTO[]) { this.queue.push(...data); + + // Check if the cache queue is full and we need to flush it. + if (this.maxQueue > 0 && this.queue.length >= this.maxQueue && this.onFullQueue) { + this.onFullQueue(); + } } /** diff --git a/src/storages/inMemory/InMemoryStorage.ts b/src/storages/inMemory/InMemoryStorage.ts index 00d666fc..6f446f20 100644 --- a/src/storages/inMemory/InMemoryStorage.ts +++ b/src/storages/inMemory/InMemoryStorage.ts @@ -16,7 +16,7 @@ export function InMemoryStorageFactory(params: IStorageFactoryParams): IStorageS return { splits: new SplitsCacheInMemory(), segments: new SegmentsCacheInMemory(), - impressions: new ImpressionsCacheInMemory(), + impressions: new ImpressionsCacheInMemory(params.impressionsQueueSize), impressionCounts: params.optimize ? new ImpressionCountsCacheInMemory() : undefined, events: new EventsCacheInMemory(params.eventsQueueSize), diff --git a/src/storages/inMemory/InMemoryStorageCS.ts b/src/storages/inMemory/InMemoryStorageCS.ts index 7ac34ea8..4fa9b962 100644 --- a/src/storages/inMemory/InMemoryStorageCS.ts +++ b/src/storages/inMemory/InMemoryStorageCS.ts @@ -16,7 +16,7 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag return { splits: new SplitsCacheInMemory(), segments: new MySegmentsCacheInMemory(), - impressions: new ImpressionsCacheInMemory(), + impressions: new ImpressionsCacheInMemory(params.impressionsQueueSize), impressionCounts: params.optimize ? new ImpressionCountsCacheInMemory() : undefined, events: new EventsCacheInMemory(params.eventsQueueSize), diff --git a/src/storages/inMemory/__tests__/ImpressionsCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/ImpressionsCacheInMemory.spec.ts index e5f7ed1b..4a50eaf8 100644 --- a/src/storages/inMemory/__tests__/ImpressionsCacheInMemory.spec.ts +++ b/src/storages/inMemory/__tests__/ImpressionsCacheInMemory.spec.ts @@ -1,13 +1,56 @@ - +// @ts-nocheck import { ImpressionsCacheInMemory } from '../ImpressionsCacheInMemory'; -test('IMPRESSIONS CACHE IN MEMORY / should incrementally store values', () => { +test('IMPRESSIONS CACHE IN MEMORY / should incrementally store values, clear the queue, and tell if it is empty', () => { const c = new ImpressionsCacheInMemory(); - // @ts-expect-error - c.track([0]); // @ts-expect-error - c.track([1, 2]); // @ts-expect-error + // queue is initially empty + expect(c.state()).toEqual([]); + expect(c.isEmpty()).toBe(true); + + + c.track([0]); + c.track([1, 2]); c.track([3]); expect(c.state()).toEqual([0, 1, 2, 3]); // all the items should be stored in sequential order + expect(c.isEmpty()).toBe(false); + + // should empty the queue + c.clear(); + expect(c.state()).toEqual([]); + expect(c.isEmpty()).toBe(true); +}); + +test('IMPRESSIONS CACHE IN MEMORY / Should call "onFullQueueCb" when the queue is full.', () => { + let cbCalled = 0; + const cache = new ImpressionsCacheInMemory(3); // small impressionsQueueSize to be reached + cache.setOnFullQueueCb(() => { cbCalled++; cache.clear(); }); + + cache.track([0]); + cache.track([0]); + expect(cbCalled).toBe(0); // if the queue is not full, it will not run the callback. + cache.track([0]); + expect(cbCalled).toBe(1); // if we had the queue full, it should flush events. + cache.track([1]); + expect(cbCalled).toBe(1); // After that, while the queue is below max size, it should not try to flush it. + cache.track([2]); + expect(cbCalled).toBe(1); // After that, while the queue is below max size, it should not try to flush it. + cache.track([3]); + expect(cbCalled).toBe(2); // Once we get to the max size, it should try to flush it. + cache.track([4]); + expect(cbCalled).toBe(2); // And it should not flush again, + cache.track([5]); + expect(cbCalled).toBe(2); // And it should not flush again, + cache.track([6]); + expect(cbCalled).toBe(3); // Until the queue is filled with events again. +}); + +test('IMPRESSIONS CACHE IN MEMORY / Should not throw if the "onFullQueueCb" callback was not provided.', () => { + const cache = new ImpressionsCacheInMemory(3); // small eventsQueueSize to be reached + + cache.track([0]); + cache.track([1]); // Cache still not full, + expect(cache.track.bind(cache, [2])).not.toThrow(); // but when it is full, as 'onFullQueueCb' was not provided, nothing happens but no exceptions are thrown. + expect(cache.track.bind(cache, [3])).not.toThrow(); // but when it is full, as 'onFullQueueCb' was not provided, nothing happens but no exceptions are thrown. }); diff --git a/src/storages/inRedis/RedisAdapter.ts b/src/storages/inRedis/RedisAdapter.ts index 05ef94f7..0c374f23 100644 --- a/src/storages/inRedis/RedisAdapter.ts +++ b/src/storages/inRedis/RedisAdapter.ts @@ -164,6 +164,12 @@ export class RedisAdapter extends ioredis { } else { // If it IS the string URL, that'll be the first param for ioredis. result.unshift(options.url); } + if (options.connectionTimeout) { + merge(opts, { connectTimeout: options.connectionTimeout }); + } + if (options.tls) { + merge(opts, { tls: options.tls }); + } return result; } @@ -171,9 +177,9 @@ export class RedisAdapter extends ioredis { /** * Parses the options into what we care about. */ - static _defineOptions({ connectionTimeout, operationTimeout, url, host, port, db, pass }: Record) { + static _defineOptions({ connectionTimeout, operationTimeout, url, host, port, db, pass, tls }: Record) { const parsedOptions = { - connectionTimeout, operationTimeout, url, host, port, db, pass + connectionTimeout, operationTimeout, url, host, port, db, pass, tls }; return merge({}, DEFAULT_OPTIONS, parsedOptions); diff --git a/src/storages/inRedis/__tests__/RedisAdapter.spec.ts b/src/storages/inRedis/__tests__/RedisAdapter.spec.ts index c9e789f6..1e9fd512 100644 --- a/src/storages/inRedis/__tests__/RedisAdapter.spec.ts +++ b/src/storages/inRedis/__tests__/RedisAdapter.spec.ts @@ -93,33 +93,36 @@ describe('STORAGE Redis Adapter', () => { new RedisAdapter(loggerMock, { url: redisUrl, connectionTimeout: 123, - operationTimeout: 124 + operationTimeout: 124, + tls: { ca: 'ca' } }); // Keep in mind we're storing the arguments object, not a true array. expect(constructorParams.length).toBe(2); // In this signature, the constructor receives two params. expect(constructorParams[0]).toBe(redisUrl); // When we use the Redis URL, that should be the first parameter passed to ioredis constructor - expect(constructorParams[1]).toEqual({ enableOfflineQueue: false, connectTimeout: 10000, lazyConnect: false }); // and the second parameter would be the default settings for the lib. + expect(constructorParams[1]).toEqual({ enableOfflineQueue: false, connectTimeout: 123, lazyConnect: false, tls: { ca: 'ca' } }); // and the second parameter would be the default settings for the lib. new RedisAdapter(loggerMock, { ...redisParams, connectionTimeout: 123, - operationTimeout: 124 + operationTimeout: 124, + tls: { ca: 'ca' } }); expect(constructorParams.length).toBe(1); // In this signature, the constructor receives one param. // we keep "pass" instead of "password" on our settings API to be backwards compatible. - expect(constructorParams[0]).toEqual({ host: redisParams.host, port: redisParams.port, db: redisParams.db, password: redisParams.pass, enableOfflineQueue: false, connectTimeout: 10000, lazyConnect: false }); // If we send all the redis params separate, it will pass one object to the library containing that and the rest of the options. + expect(constructorParams[0]).toEqual({ host: redisParams.host, port: redisParams.port, db: redisParams.db, password: redisParams.pass, enableOfflineQueue: false, connectTimeout: 123, lazyConnect: false, tls: { ca: 'ca' } }); // If we send all the redis params separate, it will pass one object to the library containing that and the rest of the options. new RedisAdapter(loggerMock, { ...redisParams, url: redisUrl, - connectionTimeout: 123, - operationTimeout: 124 + // Using default connectionTimeout + operationTimeout: 124, + tls: { ca: 'ca' } }); expect(constructorParams.length).toBe(2); // In this signature, the constructor receives two params. expect(constructorParams[0]).toBe(redisUrl); // When we use the Redis URL, even if we specified all the other params one by one the URL takes precedence, so that should be the first parameter passed to ioredis constructor - expect(constructorParams[1]).toEqual({ enableOfflineQueue: false, connectTimeout: 10000, lazyConnect: false }); // and the second parameter would be the default settings for the lib. + expect(constructorParams[1]).toEqual({ enableOfflineQueue: false, connectTimeout: 10000, lazyConnect: false, tls: { ca: 'ca' } }); // and the second parameter would be the default settings for the lib. }); test('static method - _defineOptions', () => { diff --git a/src/storages/pluggable/index.ts b/src/storages/pluggable/index.ts index fef04aa6..e4062132 100644 --- a/src/storages/pluggable/index.ts +++ b/src/storages/pluggable/index.ts @@ -63,7 +63,7 @@ export function PluggableStorage(options: PluggableStorageOptions): IStorageAsyn const prefix = validatePrefix(options.prefix); - function PluggableStorageFactory({ log, metadata, onReadyCb, mode, eventsQueueSize, optimize }: IStorageFactoryParams): IStorageAsync { + function PluggableStorageFactory({ log, metadata, onReadyCb, mode, eventsQueueSize, impressionsQueueSize, optimize }: IStorageFactoryParams): IStorageAsync { const keys = new KeyBuilderSS(prefix, metadata); const wrapper = wrapperAdapter(log, options.wrapper); const isPartialConsumer = mode === CONSUMER_PARTIAL_MODE; @@ -74,7 +74,7 @@ export function PluggableStorage(options: PluggableStorageOptions): IStorageAsyn return { splits: new SplitsCachePluggable(log, keys, wrapper), segments: new SegmentsCachePluggable(log, keys, wrapper), - impressions: isPartialConsumer ? new ImpressionsCacheInMemory() : new ImpressionsCachePluggable(log, keys.buildImpressionsKey(), wrapper, metadata), + impressions: isPartialConsumer ? new ImpressionsCacheInMemory(impressionsQueueSize) : new ImpressionsCachePluggable(log, keys.buildImpressionsKey(), wrapper, metadata), impressionCounts: optimize ? new ImpressionCountsCacheInMemory() : undefined, events: isPartialConsumer ? promisifyEventsTrack(new EventsCacheInMemory(eventsQueueSize)) : new EventsCachePluggable(log, keys.buildEventsKey(), wrapper, metadata), // @TODO add telemetry cache when required diff --git a/src/storages/types.ts b/src/storages/types.ts index 0e3a6001..e699561b 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -298,7 +298,7 @@ export interface IRecorderCacheProducerSync { // @TODO names are inconsistent with spec /* Checks if cache is empty. Returns true if the cache was just created or cleared */ isEmpty(): boolean - /* clears cache data */ + /* Clears cache data */ clear(): void /* Gets cache data */ state(): T @@ -307,10 +307,13 @@ export interface IRecorderCacheProducerSync { export interface IImpressionsCacheSync extends IImpressionsCacheBase, IRecorderCacheProducerSync { track(data: ImpressionDTO[]): void + /* Registers callback for full queue */ + setOnFullQueueCb(cb: () => void): void } export interface IEventsCacheSync extends IEventsCacheBase, IRecorderCacheProducerSync { track(data: SplitIO.EventData, size?: number): boolean + /* Registers callback for full queue */ setOnFullQueueCb(cb: () => void): void } @@ -423,6 +426,7 @@ export type DataLoader = (storage: IStorageSync, matchingKey: string) => void export interface IStorageFactoryParams { log: ILogger, + impressionsQueueSize?: number, eventsQueueSize?: number, optimize?: boolean /* whether create the `impressionCounts` cache (OPTIMIZED impression mode) or not (DEBUG impression mode) */, @@ -443,7 +447,7 @@ export interface IStorageFactoryParams { export type StorageType = 'MEMORY' | 'LOCALSTORAGE' | 'REDIS' | 'PLUGGABLE'; export type IStorageSyncFactory = { - type: StorageType, + readonly type: StorageType, (params: IStorageFactoryParams): IStorageSync } diff --git a/src/sync/__tests__/syncManagerOnline.spec.ts b/src/sync/__tests__/syncManagerOnline.spec.ts new file mode 100644 index 00000000..c47cad86 --- /dev/null +++ b/src/sync/__tests__/syncManagerOnline.spec.ts @@ -0,0 +1,42 @@ +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { syncTaskFactory } from './syncTask.mock'; + +jest.mock('../submitters/submitterManager', () => { + return { + submitterManagerFactory: syncTaskFactory + }; +}); + +import { syncManagerOnlineFactory } from '../syncManagerOnline'; + +test('syncManagerOnline should start or not the submitter depending on user consent status', () => { + const settings = { ...fullSettings }; + + // @ts-ignore + const syncManager = syncManagerOnlineFactory()({ settings }); + const submitter = syncManager.submitter!; + + syncManager.start(); + expect(submitter.start).toBeCalledTimes(1); // Submitter should be started if userConsent is undefined + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(1); + + settings.userConsent = 'UNKNOWN'; + syncManager.start(); + expect(submitter.start).toBeCalledTimes(1); // Submitter should not be started if userConsent is unknown + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(2); + + settings.userConsent = 'GRANTED'; + syncManager.start(); + expect(submitter.start).toBeCalledTimes(2); // Submitter should be started if userConsent is granted + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(3); + + settings.userConsent = 'DECLINED'; + syncManager.start(); + expect(submitter.start).toBeCalledTimes(2); // Submitter should not be started if userConsent is declined + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(4); + +}); diff --git a/src/sync/__tests__/syncTask.mock.ts b/src/sync/__tests__/syncTask.mock.ts new file mode 100644 index 00000000..10ef439e --- /dev/null +++ b/src/sync/__tests__/syncTask.mock.ts @@ -0,0 +1,9 @@ +export function syncTaskFactory() { + return { + execute: jest.fn(), + isExecuting: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + isRunning: jest.fn(), + }; +} diff --git a/src/sync/__tests__/syncTask.spec.ts b/src/sync/__tests__/syncTask.spec.ts index d4a7502b..49ecf67f 100644 --- a/src/sync/__tests__/syncTask.spec.ts +++ b/src/sync/__tests__/syncTask.spec.ts @@ -64,7 +64,7 @@ test('syncTaskFactory', (done) => { syncTask.stop(); expect(asyncTask).toBeCalledTimes(7); - // // Resume periodic execution + // Resume periodic execution syncTask.start(); // Inmediatelly call task syncTask.start(); // No effect expect(asyncTask).toBeCalledTimes(8); diff --git a/src/sync/offline/splitsParser/splitsParserFromFile.ts b/src/sync/offline/splitsParser/splitsParserFromFile.ts index d65d6a55..0c376918 100644 --- a/src/sync/offline/splitsParser/splitsParserFromFile.ts +++ b/src/sync/offline/splitsParser/splitsParserFromFile.ts @@ -82,7 +82,7 @@ export function splitsParserFromFileFactory(): ISplitsParser { try { data = fs.readFileSync(filePath, 'utf-8'); } catch (e) { - log.error(e.message); + log.error(e && (e as Error).message); return {}; } diff --git a/src/sync/polling/fetchers/mySegmentsFetcher.ts b/src/sync/polling/fetchers/mySegmentsFetcher.ts index 8ea7a5f4..498132b0 100644 --- a/src/sync/polling/fetchers/mySegmentsFetcher.ts +++ b/src/sync/polling/fetchers/mySegmentsFetcher.ts @@ -6,9 +6,10 @@ import { IMySegmentsFetcher } from './types'; * Factory of MySegments fetcher. * MySegments fetcher is a wrapper around `mySegments` API service that parses the response and handle errors. */ -export function mySegmentsFetcherFactory(fetchMySegments: IFetchMySegments, userMatchingKey: string): IMySegmentsFetcher { +export function mySegmentsFetcherFactory(fetchMySegments: IFetchMySegments): IMySegmentsFetcher { return function mySegmentsFetcher( + userMatchingKey: string, noCache?: boolean, // Optional decorator for `fetchMySegments` promise, such as timeout or time tracker decorator?: (promise: Promise) => Promise diff --git a/src/sync/polling/fetchers/types.ts b/src/sync/polling/fetchers/types.ts index 4d1272f0..e15331fb 100644 --- a/src/sync/polling/fetchers/types.ts +++ b/src/sync/polling/fetchers/types.ts @@ -15,6 +15,7 @@ export type ISegmentChangesFetcher = ( ) => Promise export type IMySegmentsFetcher = ( + userMatchingKey: string, noCache?: boolean, decorator?: (promise: Promise) => Promise ) => Promise diff --git a/src/sync/polling/pollingManagerCS.ts b/src/sync/polling/pollingManagerCS.ts index 138ed003..364829a5 100644 --- a/src/sync/polling/pollingManagerCS.ts +++ b/src/sync/polling/pollingManagerCS.ts @@ -1,26 +1,23 @@ import { ISegmentsSyncTask, ISplitsSyncTask, IPollingManagerCS } from './types'; import { forOwn } from '../../utils/lang'; import { IReadinessManager } from '../../readiness/types'; -import { ISplitApi } from '../../services/types'; import { IStorageSync } from '../../storages/types'; import { mySegmentsSyncTaskFactory } from './syncTasks/mySegmentsSyncTask'; import { splitsSyncTaskFactory } from './syncTasks/splitsSyncTask'; -import { ISettings } from '../../types'; import { getMatching } from '../../utils/key'; import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../../readiness/constants'; import { POLLING_SMART_PAUSING, POLLING_START, POLLING_STOP } from '../../logger/constants'; +import { ISyncManagerFactoryParams } from '../types'; /** * Expose start / stop mechanism for polling data from services. * For client-side API with multiple clients. */ export function pollingManagerCSFactory( - splitApi: ISplitApi, - storage: IStorageSync, - readiness: IReadinessManager, - settings: ISettings, + params: ISyncManagerFactoryParams ): IPollingManagerCS { + const { splitApi, storage, readiness, settings } = params; const log = settings.log; const splitsSyncTask: ISplitsSyncTask = splitsSyncTaskFactory(splitApi.fetchSplitChanges, storage, readiness, settings); diff --git a/src/sync/polling/pollingManagerSS.ts b/src/sync/polling/pollingManagerSS.ts index c1e7edad..2c7d3ec9 100644 --- a/src/sync/polling/pollingManagerSS.ts +++ b/src/sync/polling/pollingManagerSS.ts @@ -1,23 +1,18 @@ import { splitsSyncTaskFactory } from './syncTasks/splitsSyncTask'; import { segmentsSyncTaskFactory } from './syncTasks/segmentsSyncTask'; -import { IStorageSync } from '../../storages/types'; -import { IReadinessManager } from '../../readiness/types'; -import { ISplitApi } from '../../services/types'; -import { ISettings } from '../../types'; import { IPollingManager, ISegmentsSyncTask, ISplitsSyncTask } from './types'; import { thenable } from '../../utils/promise/thenable'; import { POLLING_START, POLLING_STOP, LOG_PREFIX_SYNC_POLLING } from '../../logger/constants'; +import { ISyncManagerFactoryParams } from '../types'; /** * Expose start / stop mechanism for pulling data from services. */ export function pollingManagerSSFactory( - splitApi: ISplitApi, - storage: IStorageSync, - readiness: IReadinessManager, - settings: ISettings + params: ISyncManagerFactoryParams ): IPollingManager { + const { splitApi, storage, readiness, settings } = params; const log = settings.log; const splitsSyncTask: ISplitsSyncTask = splitsSyncTaskFactory(splitApi.fetchSplitChanges, storage, readiness, settings); diff --git a/src/sync/polling/syncTasks/mySegmentsSyncTask.ts b/src/sync/polling/syncTasks/mySegmentsSyncTask.ts index 7b830d21..30f521b0 100644 --- a/src/sync/polling/syncTasks/mySegmentsSyncTask.ts +++ b/src/sync/polling/syncTasks/mySegmentsSyncTask.ts @@ -21,12 +21,13 @@ export function mySegmentsSyncTaskFactory( settings.log, mySegmentsUpdaterFactory( settings.log, - mySegmentsFetcherFactory(fetchMySegments, matchingKey), + mySegmentsFetcherFactory(fetchMySegments), storage.splits, storage.segments, readiness.segments, settings.startup.requestTimeoutBeforeReady, settings.startup.retriesOnFailureBeforeReady, + matchingKey ), settings.scheduler.segmentsRefreshRate, 'mySegmentsUpdater', diff --git a/src/sync/polling/types.ts b/src/sync/polling/types.ts index f3cf3639..3908152a 100644 --- a/src/sync/polling/types.ts +++ b/src/sync/polling/types.ts @@ -1,7 +1,5 @@ import { IReadinessManager } from '../../readiness/types'; -import { ISplitApi } from '../../services/types'; import { IStorageSync } from '../../storages/types'; -import { ISettings } from '../../types'; import { SegmentsData } from '../streaming/SSEHandler/types'; import { ITask, ISyncTask } from '../types'; @@ -23,13 +21,3 @@ export interface IPollingManagerCS extends IPollingManager { remove(matchingKey: string): void; get(matchingKey: string): ISegmentsSyncTask | undefined } - -/** - * Signature of polling manager factory/constructor - */ -export type IPollingManagerFactoryParams = [ - splitApi: ISplitApi, - storage: IStorageSync, - readiness: IReadinessManager, - settings: ISettings, -] diff --git a/src/sync/polling/updaters/mySegmentsUpdater.ts b/src/sync/polling/updaters/mySegmentsUpdater.ts index a72ee54b..486cbe00 100644 --- a/src/sync/polling/updaters/mySegmentsUpdater.ts +++ b/src/sync/polling/updaters/mySegmentsUpdater.ts @@ -23,6 +23,7 @@ export function mySegmentsUpdaterFactory( segmentsEventEmitter: ISegmentsEventEmitter, requestTimeoutBeforeReady: number, retriesOnFailureBeforeReady: number, + matchingKey: string ): IMySegmentsUpdater { let readyOnAlreadyExistentState = true; @@ -69,7 +70,7 @@ export function mySegmentsUpdaterFactory( // If segmentsData is provided, there is no need to fetch mySegments new Promise((res) => { updateSegments(segmentsData); res(true); }) : // If not provided, fetch mySegments - mySegmentsFetcher(noCache, _promiseDecorator).then(segments => { + mySegmentsFetcher(matchingKey, noCache, _promiseDecorator).then(segments => { // Only when we have downloaded segments completely, we should not keep retrying anymore startingUp = false; diff --git a/src/sync/streaming/AuthClient/index.ts b/src/sync/streaming/AuthClient/index.ts index 96ae9ca8..b8d81c55 100644 --- a/src/sync/streaming/AuthClient/index.ts +++ b/src/sync/streaming/AuthClient/index.ts @@ -17,8 +17,7 @@ export function authenticateFactory(fetchAuth: IFetchAuth): IAuthenticate { * @param {string[] | undefined} userKeys set of user Keys to track MY_SEGMENTS_CHANGES. It is undefined for server-side API. */ return function authenticate(userKeys?: string[]): Promise { - let authPromise = fetchAuth(userKeys); // errors handled by fetchAuth service - return authPromise + return fetchAuth(userKeys) .then(resp => resp.json()) .then(json => { if (json.token) { // empty token when `"pushEnabled": false` diff --git a/src/sync/streaming/SSEClient/index.ts b/src/sync/streaming/SSEClient/index.ts index 378ac9c0..7c0dfb6e 100644 --- a/src/sync/streaming/SSEClient/index.ts +++ b/src/sync/streaming/SSEClient/index.ts @@ -1,5 +1,6 @@ import { IEventSourceConstructor } from '../../../services/types'; import { ISettings } from '../../../types'; +import { isString } from '../../../utils/lang'; import { IAuthTokenPushEnabled } from '../AuthClient/types'; import { ISSEClient, ISseEventHandler } from './types'; @@ -15,7 +16,7 @@ const CONTROL_CHANNEL_REGEX = /^control_/; */ function buildSSEHeaders(settings: ISettings) { const headers: Record = { - SplitSDKClientKey: settings.core.authorizationKey.slice(-4), + SplitSDKClientKey: isString(settings.core.authorizationKey) ? settings.core.authorizationKey.slice(-4) : '', SplitSDKVersion: settings.version, }; diff --git a/src/sync/streaming/UpdateWorkers/SegmentsUpdateWorker.ts b/src/sync/streaming/UpdateWorkers/SegmentsUpdateWorker.ts index 5c52872d..03889a25 100644 --- a/src/sync/streaming/UpdateWorkers/SegmentsUpdateWorker.ts +++ b/src/sync/streaming/UpdateWorkers/SegmentsUpdateWorker.ts @@ -19,7 +19,7 @@ export class SegmentsUpdateWorker implements IUpdateWorker { * @param {Object} segmentsCache segments data cache * @param {Object} segmentsSyncTask task for syncing segments data */ - constructor(segmentsCache: ISegmentsCacheSync, segmentsSyncTask: ISegmentsSyncTask) { + constructor(segmentsSyncTask: ISegmentsSyncTask, segmentsCache: ISegmentsCacheSync) { this.segmentsCache = segmentsCache; this.segmentsSyncTask = segmentsSyncTask; this.maxChangeNumbers = {}; diff --git a/src/sync/streaming/UpdateWorkers/__tests__/SegmentsUpdateWorker.spec.ts b/src/sync/streaming/UpdateWorkers/__tests__/SegmentsUpdateWorker.spec.ts index 4a494657..e3168593 100644 --- a/src/sync/streaming/UpdateWorkers/__tests__/SegmentsUpdateWorker.spec.ts +++ b/src/sync/streaming/UpdateWorkers/__tests__/SegmentsUpdateWorker.spec.ts @@ -47,7 +47,7 @@ describe('SegmentsUpdateWorker ', () => { cache.addToSegment('mocked_segment_3', ['e']); const segmentsSyncTask = segmentsSyncTaskMock(cache); - const segmentsUpdateWorker = new SegmentsUpdateWorker(cache, segmentsSyncTask); + const segmentsUpdateWorker = new SegmentsUpdateWorker(segmentsSyncTask, cache); segmentsUpdateWorker.backoff.baseMillis = 0; // retry immediately expect(segmentsUpdateWorker.maxChangeNumbers).toEqual({}); // inits with not queued events; diff --git a/src/sync/streaming/__tests__/mySegmentsV2utils.spec.ts b/src/sync/streaming/__tests__/mySegmentsV2utils.spec.ts index b2826848..b24c6cad 100644 --- a/src/sync/streaming/__tests__/mySegmentsV2utils.spec.ts +++ b/src/sync/streaming/__tests__/mySegmentsV2utils.spec.ts @@ -16,19 +16,19 @@ test('parseKeyList', () => { addedUserKeys.forEach(userKey => { const hash = hash64(userKey); expect(added.has(hash.dec)).toBe(true); // key hash belongs to added list - expect(removed.has(hash.dec)).toBe(false); // t belong to removed list + expect(removed.has(hash.dec)).toBe(false); // key hash doesn\'t belong to removed list }); removedUserKeys.forEach(userKey => { const hash = hash64(userKey); - expect(added.has(hash.dec)).toBe(false); // t belong to added list + expect(added.has(hash.dec)).toBe(false); // key hash doesn\'t belong to added list expect(removed.has(hash.dec)).toBe(true); // key hash belongs to removed list }); otherUserKeys.forEach(userKey => { const hash = hash64(userKey); - expect(added.has(hash.dec)).toBe(false); // t belong to added list - expect(removed.has(hash.dec)).toBe(false); // t belong to removed list + expect(added.has(hash.dec)).toBe(false); // key hash doesn\'t belong to added list + expect(removed.has(hash.dec)).toBe(false); // key hash doesn\'t belong to removed list }); }); }); @@ -47,7 +47,7 @@ test('parseBitmap & isInBitmap', () => { falseUserKeys.forEach(userKey => { const hash = hash64(userKey); - expect(isInBitmap(actualBitmap, hash.hex)).toBe(false); // t belong to Bitmap + expect(isInBitmap(actualBitmap, hash.hex)).toBe(false); // key hash doesn\'t belong to Bitmap }); }); }); diff --git a/src/sync/streaming/__tests__/pushManager.spec.ts b/src/sync/streaming/__tests__/pushManager.spec.ts index 26b63f7d..fb5fc39d 100644 --- a/src/sync/streaming/__tests__/pushManager.spec.ts +++ b/src/sync/streaming/__tests__/pushManager.spec.ts @@ -1,36 +1,194 @@ -import { pushManagerFactory } from '../pushManager'; import { EventEmitter } from '../../../utils/MinEvents'; -import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; -import { IPushManagerCS } from '../types'; +import { fullSettings, fullSettingsServerSide } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { syncTaskFactory } from '../../__tests__/syncTask.mock'; + +// Test target +import { pushManagerFactory } from '../pushManager'; +import { IPushManager } from '../types'; -const platformMock = { - getEventSource: jest.fn(() => { return () => { }; }), - EventEmitter +const paramsMock = { + platform: { + getEventSource: jest.fn(() => { return () => { }; }), + EventEmitter + }, + settings: fullSettings, + storage: {}, + readiness: {} }; test('pushManagerFactory returns undefined if EventSource is not available', () => { - // @ts-ignore - const pushManager = pushManagerFactory({}, {}, {}, () => { }, { - getEventSource: () => undefined, - EventEmitter - }, fullSettings); + const params = { + ...paramsMock, + platform: { + getEventSource: () => undefined, + EventEmitter + } + }; // @ts-ignore + const pushManagerCS = pushManagerFactory(params, {}); // @ts-ignore + const pushManagerSS = pushManagerFactory(params, {}); + + expect(pushManagerCS).toBe(undefined); + expect(pushManagerSS).toBe(undefined); +}); + +describe('pushManager in client-side', () => { + + test('does not connect to streaming if it is stopped inmediatelly after being started', async () => { + const fetchAuthMock = jest.fn(); + + const pushManager = pushManagerFactory({ // @ts-ignore + ...paramsMock, splitApi: { fetchAuth: fetchAuthMock } + }, {}) as IPushManager; + + pushManager.start(); + pushManager.start(); + pushManager.stop(); + pushManager.stop(); + pushManager.start(); + pushManager.stop(); + + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).not.toBeCalled(); + }); + + test('re-authenticates asynchronously when it is resumed and when new users are added', async () => { + const fetchAuthMock = jest.fn(() => Promise.reject({ message: 'error' })); + + const pushManager = pushManagerFactory({ // @ts-ignore + ...paramsMock, splitApi: { fetchAuth: fetchAuthMock }, settings: { + ...fullSettings, + scheduler: { + ...fullSettings.scheduler, + pushRetryBackoffBase: 10 // high authentication backoff + } + } + }, {}) as IPushManager; - expect(pushManager).toBe(undefined); + // calling start again has no effect (authenticates asynchronously only once) + expect(pushManager.isRunning()).toBe(false); + pushManager.start(); + pushManager.stop(); + pushManager.start(); + pushManager.start(); + expect(pushManager.isRunning()).toBe(true); + + // authenticates asynchronously, only once for both users + const mySegmentsSyncTask = syncTaskFactory(); + pushManager.add('user2', mySegmentsSyncTask); + expect(fetchAuthMock).toHaveBeenCalledTimes(0); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenLastCalledWith([fullSettings.core.key, 'user2']); + + // re-authenticates asynchronously due to new users + pushManager.add('user3', mySegmentsSyncTask); + pushManager.add('user4', mySegmentsSyncTask); + expect(fetchAuthMock).toHaveBeenCalledTimes(1); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenLastCalledWith([fullSettings.core.key, 'user2', 'user3', 'user4']); + + // pausing + expect(pushManager.isRunning()).toBe(true); + pushManager.stop(); + expect(pushManager.isRunning()).toBe(false); + pushManager.stop(); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenCalledTimes(2); + + // re-authenticates asynchronously when resuming + pushManager.start(); + pushManager.start(); + expect(fetchAuthMock).toHaveBeenCalledTimes(2); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenLastCalledWith(['user2', 'user3', 'user4', fullSettings.core.key]); + expect(fetchAuthMock).toHaveBeenCalledTimes(3); + + // doesn't re-authenticate if a user is removed + pushManager.remove('user3'); + expect(fetchAuthMock).toHaveBeenCalledTimes(3); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenCalledTimes(3); + + // re-authenticates asynchronously when resuming, considering the updated list of users + pushManager.stop(); + pushManager.start(); + pushManager.stop(); + pushManager.start(); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenLastCalledWith(['user2', 'user4', fullSettings.core.key]); + expect(fetchAuthMock).toHaveBeenCalledTimes(4); + pushManager.stop(); + }); }); -test('pushManager does not connect to streaming if it is stopped inmediatelly after being started', (done) => { - const fetchAuthMock = jest.fn(); +describe('pushManager in server-side', () => { + + test('does not connect to streaming if it is stopped inmediatelly after being started', async () => { + const fetchAuthMock = jest.fn(); - // @ts-ignore - const pushManager = pushManagerFactory({}, {}, {}, fetchAuthMock, platformMock, fullSettings) as IPushManagerCS; + const pushManager = pushManagerFactory({ // @ts-ignore + ...paramsMock, splitApi: { fetchAuth: fetchAuthMock }, settings: fullSettingsServerSide + }, {}) as IPushManager; - pushManager.start(); - pushManager.start(); - pushManager.stop(); - pushManager.stop(); + pushManager.start(); + pushManager.start(); + pushManager.stop(); + pushManager.stop(); + pushManager.start(); + pushManager.stop(); - setTimeout(() => { + await new Promise(res => setTimeout(res)); expect(fetchAuthMock).not.toBeCalled(); - done(); }); + + test('re-authenticates asynchronously when it is resumed', async () => { + const fetchAuthMock = jest.fn(() => Promise.reject({ message: 'error' })); + + const pushManager = pushManagerFactory({ // @ts-ignore + ...paramsMock, splitApi: { fetchAuth: fetchAuthMock }, settings: { + ...fullSettingsServerSide, + scheduler: { + ...fullSettingsServerSide.scheduler, + pushRetryBackoffBase: 10 // high authentication backoff + } + } + }, {}) as IPushManager; + + // calling start again has no effect (authenticates asynchronously only once) + expect(pushManager.isRunning()).toBe(false); + pushManager.start(); + pushManager.start(); + expect(pushManager.isRunning()).toBe(true); + // @TODO pausing & resuming synchronously is not working as expected in server-side + // pushManager.stop(); + // pushManager.start(); + expect(fetchAuthMock).toHaveBeenCalledTimes(0); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenLastCalledWith(undefined); + + // pausing + expect(pushManager.isRunning()).toBe(true); + pushManager.stop(); + pushManager.stop(); + expect(pushManager.isRunning()).toBe(false); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenCalledTimes(1); + + // re-authenticates asynchronously when resuming + pushManager.start(); + pushManager.start(); + expect(fetchAuthMock).toHaveBeenCalledTimes(1); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenCalledTimes(2); + + // re-authenticates asynchronously when stopping/resuming + pushManager.stop(); + pushManager.stop(); + pushManager.start(); + pushManager.start(); + expect(fetchAuthMock).toHaveBeenCalledTimes(2); + await new Promise(res => setTimeout(res)); + expect(fetchAuthMock).toHaveBeenCalledTimes(3); + pushManager.stop(); + }); + }); diff --git a/src/sync/streaming/pushManager.ts b/src/sync/streaming/pushManager.ts index f5130447..ec5ff916 100644 --- a/src/sync/streaming/pushManager.ts +++ b/src/sync/streaming/pushManager.ts @@ -1,7 +1,5 @@ -import { IPushEventEmitter, IPushManagerCS } from './types'; +import { IPushEventEmitter, IPushManager } from './types'; import { ISSEClient } from './SSEClient/types'; -import { IStorageSync } from '../../storages/types'; -import { IReadinessManager } from '../../readiness/types'; import { ISegmentsSyncTask, IPollingManager } from '../polling/types'; import { objectAssign } from '../../utils/lang/objectAssign'; import { Backoff } from '../../utils/Backoff'; @@ -12,17 +10,15 @@ import { SplitsUpdateWorker } from './UpdateWorkers/SplitsUpdateWorker'; import { authenticateFactory, hashUserKey } from './AuthClient'; import { forOwn } from '../../utils/lang'; import { SSEClient } from './SSEClient'; -import { IFetchAuth } from '../../services/types'; -import { ISettings } from '../../types'; import { getMatching } from '../../utils/key'; import { MY_SEGMENTS_UPDATE, MY_SEGMENTS_UPDATE_V2, PUSH_NONRETRYABLE_ERROR, PUSH_SUBSYSTEM_DOWN, SECONDS_BEFORE_EXPIRATION, SEGMENT_UPDATE, SPLIT_KILL, SPLIT_UPDATE, PUSH_RETRYABLE_ERROR, PUSH_SUBSYSTEM_UP, ControlType } from './constants'; -import { IPlatform } from '../../sdkFactory/types'; import { STREAMING_FALLBACK, STREAMING_REFRESH_TOKEN, STREAMING_CONNECTING, STREAMING_DISABLED, ERROR_STREAMING_AUTH, STREAMING_DISCONNECTING, STREAMING_RECONNECT, STREAMING_PARSING_MY_SEGMENTS_UPDATE_V2 } from '../../logger/constants'; import { KeyList, UpdateStrategy } from './SSEHandler/types'; import { isInBitmap, parseBitmap, parseKeyList } from './mySegmentsV2utils'; import { ISet, _Set } from '../../utils/lang/sets'; import { Hash64, hash64 } from '../../utils/murmur3/murmur3_64'; import { IAuthTokenPushEnabled } from './AuthClient/types'; +import { ISyncManagerFactoryParams } from '../types'; /** * PushManager factory: @@ -30,17 +26,15 @@ import { IAuthTokenPushEnabled } from './AuthClient/types'; * - for client-side, with support for multiple clients, if key is provided in settings */ export function pushManagerFactory( + params: ISyncManagerFactoryParams, pollingManager: IPollingManager, - storage: IStorageSync, - readiness: IReadinessManager, - fetchAuth: IFetchAuth, - platform: IPlatform, - settings: ISettings, -): IPushManagerCS | undefined { +): IPushManager | undefined { + + const { settings, storage, splitApi, readiness, platform } = params; // `userKey` is the matching key of main client in client-side SDK. // It can be used to check if running on client-side or server-side SDK. - const userKey = settings.core.key ? getMatching(settings.core.key) : undefined; // + const userKey = settings.core.key ? getMatching(settings.core.key) : undefined; const log = settings.log; let sseClient: ISSEClient; @@ -51,7 +45,7 @@ export function pushManagerFactory( log.warn(STREAMING_FALLBACK, [e]); return; } - const authenticate = authenticateFactory(fetchAuth); + const authenticate = authenticateFactory(splitApi.fetchAuth); // init feedback loop const pushEmitter = new platform.EventEmitter() as IPushEventEmitter; @@ -59,7 +53,8 @@ export function pushManagerFactory( sseClient.setEventHandler(sseHandler); // init workers - const segmentsUpdateWorker = userKey ? new MySegmentsUpdateWorker(pollingManager.segmentsSyncTask) : new SegmentsUpdateWorker(storage.segments, pollingManager.segmentsSyncTask); + // MySegmentsUpdateWorker (client-side) are initiated in `add` method + const segmentsUpdateWorker = userKey ? undefined : new SegmentsUpdateWorker(pollingManager.segmentsSyncTask, storage.segments); // For server-side we pass the segmentsSyncTask, used by SplitsUpdateWorker to fetch new segments const splitsUpdateWorker = new SplitsUpdateWorker(storage.splits, pollingManager.splitsSyncTask, readiness.splits, userKey ? undefined : pollingManager.segmentsSyncTask); @@ -68,11 +63,6 @@ export function pushManagerFactory( // [Only for client-side] map of user keys to their corresponding hash64 and MySegmentsUpdateWorkers. // Hash64 is used to process MY_SEGMENTS_UPDATE_V2 events and dispatch actions to the corresponding MySegmentsUpdateWorker. const clients: Record = {}; - if (userKey) { - const hash = hashUserKey(userKey); - userKeyHashes[hash] = userKey; - clients[userKey] = { hash64: hash64(userKey), worker: segmentsUpdateWorker as MySegmentsUpdateWorker }; - } // [Only for client-side] variable to flag that a new client was added. It is needed to reconnect streaming. let connectForNewClient = false; @@ -81,6 +71,7 @@ export function pushManagerFactory( // It is used to halt the `connectPush` process if it was in progress. let disconnected: boolean | undefined; // flag that indicates a PUSH_NONRETRYABLE_ERROR, condition with which starting pushManager again is ignored. + // true if STREAMING_DISABLED control event, or 'pushEnabled: false', or non-recoverable SSE or Auth errors. let disabled: boolean | undefined; // `disabled` implies `disconnected === true` /** PushManager functions related to initialization */ @@ -176,7 +167,7 @@ export function pushManagerFactory( function stopWorkers() { splitsUpdateWorker.backoff.reset(); if (userKey) forOwn(clients, ({ worker }) => worker.backoff.reset()); - else segmentsUpdateWorker.backoff.reset(); + else (segmentsUpdateWorker as SegmentsUpdateWorker).backoff.reset(); } pushEmitter.on(PUSH_SUBSYSTEM_DOWN, stopWorkers); @@ -306,37 +297,48 @@ export function pushManagerFactory( // Expose Event Emitter functionality and Event constants Object.create(pushEmitter), { - // Expose functionality for starting and stoping push mode: - stop: disconnectPush, // `handleNonRetryableError` cannot be used as `stop`, because it emits PUSH_SUBSYSTEM_DOWN event, which starts polling. + // Stop/pause push mode. + // It doesn't emit events. Neither PUSH_SUBSYSTEM_DOWN to start polling. + stop() { + disconnectPush(); // `handleNonRetryableError` cannot be used as `stop`, because it emits PUSH_SUBSYSTEM_DOWN event, which starts polling. + if (userKey) this.remove(userKey); // Necessary to properly resume streaming in client-side (e.g., RN SDK transition to foreground). + }, + // Start/resume push mode. + // It eventually emits PUSH_SUBSYSTEM_DOWN, that starts polling, or PUSH_SUBSYSTEM_UP, that executes a syncAll start() { // Guard condition to avoid calling `connectPush` again if the `start` method is called multiple times or if push has been disabled. if (disabled || disconnected === false) return; disconnected = false; - // Run in next event-loop cycle for optimization on client-side: if multiple clients are created in the same cycle than the factory, only one authentication is performed. - setTimeout(connectPush); + + if (userKey) this.add(userKey, pollingManager.segmentsSyncTask); // client-side + else setTimeout(connectPush); // server-side runs in next cycle as in client-side, for consistency with client-side + }, + + // true/false if start or stop was called last respectively + isRunning(){ + return disconnected === false; }, // [Only for client-side] add(userKey: string, mySegmentsSyncTask: ISegmentsSyncTask) { - clients[userKey] = { hash64: hash64(userKey), worker: new MySegmentsUpdateWorker(mySegmentsSyncTask) }; - const hash = hashUserKey(userKey); if (!userKeyHashes[hash]) { userKeyHashes[hash] = userKey; + clients[userKey] = { hash64: hash64(userKey), worker: new MySegmentsUpdateWorker(mySegmentsSyncTask) }; connectForNewClient = true; // we must reconnect on start, to listen the channel for the new user key - } - // Reconnects in case of a new client. - // Run in next event-loop cycle to save authentication calls - // in case the user is creating several clients in the current cycle. - setTimeout(function checkForReconnect() { - if (connectForNewClient) { - connectForNewClient = false; - connectPush(); - } - }, 0); + // Reconnects in case of a new client. + // Run in next event-loop cycle to save authentication calls + // in case multiple clients are created in the current cycle. + setTimeout(function checkForReconnect() { + if (connectForNewClient) { + connectForNewClient = false; + connectPush(); + } + }, 0); + } }, // [Only for client-side] remove(userKey: string) { diff --git a/src/sync/streaming/types.ts b/src/sync/streaming/types.ts index 6832a335..57a4a96a 100644 --- a/src/sync/streaming/types.ts +++ b/src/sync/streaming/types.ts @@ -1,11 +1,7 @@ import { IMySegmentsUpdateData, IMySegmentsUpdateV2Data, ISegmentUpdateData, ISplitUpdateData, ISplitKillData } from './SSEHandler/types'; import { ITask } from '../types'; -import { IPollingManager, ISegmentsSyncTask } from '../polling/types'; -import { IReadinessManager } from '../../readiness/types'; -import { IFetchAuth } from '../../services/types'; -import { IStorageSync } from '../../storages/types'; -import { IEventEmitter, ISettings } from '../../types'; -import { IPlatform } from '../../sdkFactory/types'; +import { ISegmentsSyncTask } from '../polling/types'; +import { IEventEmitter } from '../../types'; import { ControlType } from './constants'; // Internal SDK events, subscribed by SyncManager and PushManager @@ -45,26 +41,10 @@ export interface IPushEventEmitter extends IEventEmitter { } /** - * PushManager for server-side + * PushManager */ -export interface IPushManager extends ITask, IPushEventEmitter { } - -/** - * PushManager for client-side with support for multiple clients - */ -export interface IPushManagerCS extends IPushManager { +export interface IPushManager extends ITask, IPushEventEmitter { + // Methods used in client-side, to support multiple clients add(userKey: string, mySegmentsSyncTask: ISegmentsSyncTask): void, remove(userKey: string): void } - -/** - * Signature of push manager factory/constructor - */ -export type IPushManagerFactoryParams = [ - pollingManager: IPollingManager, - storage: IStorageSync, - readiness: IReadinessManager, - fetchAuth: IFetchAuth, - platform: IPlatform, - settings: ISettings -] diff --git a/src/sync/submitters/__tests__/eventsSyncTask.spec.ts b/src/sync/submitters/__tests__/eventsSyncTask.spec.ts new file mode 100644 index 00000000..59b647c1 --- /dev/null +++ b/src/sync/submitters/__tests__/eventsSyncTask.spec.ts @@ -0,0 +1,61 @@ +import { eventsSyncTaskFactory } from '../eventsSyncTask'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + + + +describe('Events submitter (eventsSyncTask)', () => { + + let __onFullQueueCb: () => void; + const postEventsBulkMock = jest.fn(); + const eventsCacheMock = { + isEmpty: jest.fn(() => true), + setOnFullQueueCb: jest.fn(function (onFullQueueCb) { __onFullQueueCb = onFullQueueCb; }) + }; + + beforeEach(() => { + eventsCacheMock.isEmpty.mockClear(); + }); + + test('with eventsFirstPushWindow', async () => { + const eventsFirstPushWindow = 20; // @ts-ignore + const eventsSubmitter = eventsSyncTaskFactory(loggerMock, postEventsBulkMock, eventsCacheMock, 30000, eventsFirstPushWindow); + + eventsSubmitter.start(); + expect(eventsSubmitter.isRunning()).toEqual(true); // Submitter should be flagged as running + expect(eventsSubmitter.isExecuting()).toEqual(false); // but not executed immediatelly if there is a push window + expect(eventsCacheMock.isEmpty).not.toBeCalled(); + + // If queue is full, submitter should be executed + __onFullQueueCb(); + expect(eventsSubmitter.isExecuting()).toEqual(true); + expect(eventsCacheMock.isEmpty).toBeCalledTimes(1); + + // Await first push window + await new Promise(res => setTimeout(res, eventsFirstPushWindow + 10)); + expect(eventsCacheMock.isEmpty).toBeCalledTimes(2); // after the push window, submitter should have been executed + + expect(eventsSubmitter.isRunning()).toEqual(true); + eventsSubmitter.stop(); + expect(eventsSubmitter.isRunning()).toEqual(false); + }); + + test('without eventsFirstPushWindow', async () => { + // @ts-ignore + const eventsSubmitter = eventsSyncTaskFactory(loggerMock, postEventsBulkMock, eventsCacheMock, 30000); + + eventsSubmitter.start(); + expect(eventsSubmitter.isRunning()).toEqual(true); // Submitter should be flagged as running + expect(eventsSubmitter.isExecuting()).toEqual(true); // and executes immediatelly if there isn't a push window + expect(eventsCacheMock.isEmpty).toBeCalledTimes(1); + + // If queue is full, submitter should be executed + __onFullQueueCb(); + expect(eventsSubmitter.isExecuting()).toEqual(true); + expect(eventsCacheMock.isEmpty).toBeCalledTimes(2); + + expect(eventsSubmitter.isRunning()).toEqual(true); + eventsSubmitter.stop(); + expect(eventsSubmitter.isRunning()).toEqual(false); + }); + +}); diff --git a/src/sync/submitters/eventsSyncTask.ts b/src/sync/submitters/eventsSyncTask.ts index 3a15a6e0..7c84374b 100644 --- a/src/sync/submitters/eventsSyncTask.ts +++ b/src/sync/submitters/eventsSyncTask.ts @@ -3,7 +3,9 @@ import { IPostEventsBulk } from '../../services/types'; import { ISyncTask, ITimeTracker } from '../types'; import { submitterSyncTaskFactory } from './submitterSyncTask'; import { ILogger } from '../../logger/types'; -import { SUBMITTERS_PUSH_FULL_EVENTS_QUEUE } from '../../logger/constants'; +import { SUBMITTERS_PUSH_FULL_QUEUE } from '../../logger/constants'; + +const DATA_NAME = 'events'; /** * Sync task that periodically posts tracked events @@ -18,26 +20,37 @@ export function eventsSyncTaskFactory( ): ISyncTask { // don't retry events. - const syncTask = submitterSyncTaskFactory(log, postEventsBulk, eventsCache, eventsPushRate, 'queued events', latencyTracker); + const syncTask = submitterSyncTaskFactory(log, postEventsBulk, eventsCache, eventsPushRate, DATA_NAME, latencyTracker); - // Set a timer for the first push of events, + // Set a timer for the first push window of events. + // Not implemented in the base submitter or sync task, since this feature is only used by the events submitter. if (eventsFirstPushWindow > 0) { + let running = false; let stopEventPublisherTimeout: ReturnType; const originalStart = syncTask.start; syncTask.start = () => { + running = true; stopEventPublisherTimeout = setTimeout(originalStart, eventsFirstPushWindow); }; const originalStop = syncTask.stop; syncTask.stop = () => { + running = false; clearTimeout(stopEventPublisherTimeout); originalStop(); }; + syncTask.isRunning = () => { + return running; + }; } - // register eventsSubmitter to be executed when events cache is full + // register events submitter to be executed when events cache is full eventsCache.setOnFullQueueCb(() => { - log.info(SUBMITTERS_PUSH_FULL_EVENTS_QUEUE); - syncTask.execute(); + if (syncTask.isRunning()) { + log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); + syncTask.execute(); + } + // If submitter is stopped (e.g., user consent declined or unknown, or app state offline), we don't send the data. + // Data will be sent when submitter is resumed. }); return syncTask; diff --git a/src/sync/submitters/impressionsSyncTask.ts b/src/sync/submitters/impressionsSyncTask.ts index cf6b6068..f3fdb20c 100644 --- a/src/sync/submitters/impressionsSyncTask.ts +++ b/src/sync/submitters/impressionsSyncTask.ts @@ -6,6 +6,9 @@ import { ImpressionDTO } from '../../types'; import { submitterSyncTaskFactory } from './submitterSyncTask'; import { ImpressionsPayload } from './types'; import { ILogger } from '../../logger/types'; +import { SUBMITTERS_PUSH_FULL_QUEUE } from '../../logger/constants'; + +const DATA_NAME = 'impressions'; /** * Converts `impressions` data from cache into request payload. @@ -50,5 +53,17 @@ export function impressionsSyncTaskFactory( ): ISyncTask { // retry impressions only once. - return submitterSyncTaskFactory(log, postTestImpressionsBulk, impressionsCache, impressionsRefreshRate, 'impressions', latencyTracker, fromImpressionsCollector.bind(undefined, sendLabels), 1); + const syncTask = submitterSyncTaskFactory(log, postTestImpressionsBulk, impressionsCache, impressionsRefreshRate, DATA_NAME, latencyTracker, fromImpressionsCollector.bind(undefined, sendLabels), 1); + + // register impressions submitter to be executed when impressions cache is full + impressionsCache.setOnFullQueueCb(() => { + if (syncTask.isRunning()) { + log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); + syncTask.execute(); + } + // If submitter is stopped (e.g., user consent declined or unknown, or app state offline), we don't send the data. + // Data will be sent when submitter is resumed. + }); + + return syncTask; } diff --git a/src/sync/submitters/submitterManager.ts b/src/sync/submitters/submitterManager.ts index 7828d947..51440a25 100644 --- a/src/sync/submitters/submitterManager.ts +++ b/src/sync/submitters/submitterManager.ts @@ -2,15 +2,11 @@ import { syncTaskComposite } from '../syncTaskComposite'; import { eventsSyncTaskFactory } from './eventsSyncTask'; import { impressionsSyncTaskFactory } from './impressionsSyncTask'; import { impressionCountsSyncTaskFactory } from './impressionCountsSyncTask'; -import { ISplitApi } from '../../services/types'; -import { IStorageSync } from '../../storages/types'; -import { ISettings } from '../../types'; +import { ISyncManagerFactoryParams } from '../types'; -export function submitterManagerFactory( - settings: ISettings, - storage: IStorageSync, - splitApi: ISplitApi, -) { +export function submitterManagerFactory(params: ISyncManagerFactoryParams) { + + const { settings, storage, splitApi } = params; const log = settings.log; const submitters = [ impressionsSyncTaskFactory(log, splitApi.postTestImpressionsBulk, storage.impressions, settings.scheduler.impressionsRefreshRate, settings.core.labelsEnabled), diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index c8264106..235c18c8 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -2,10 +2,11 @@ import { ISyncManagerCS, ISyncManagerFactoryParams } from './types'; import { submitterManagerFactory } from './submitters/submitterManager'; import { IReadinessManager } from '../readiness/types'; import { IStorageSync } from '../storages/types'; -import { IPushManagerFactoryParams, IPushManager, IPushManagerCS } from './streaming/types'; -import { IPollingManager, IPollingManagerCS, IPollingManagerFactoryParams } from './polling/types'; +import { IPushManager } from './streaming/types'; +import { IPollingManager, IPollingManagerCS } from './polling/types'; import { PUSH_SUBSYSTEM_UP, PUSH_SUBSYSTEM_DOWN } from './streaming/constants'; import { SYNC_START_POLLING, SYNC_CONTINUE_POLLING, SYNC_STOP_POLLING } from '../logger/constants'; +import { isConsentGranted } from '../consent'; /** * Online SyncManager factory. @@ -16,44 +17,37 @@ import { SYNC_START_POLLING, SYNC_CONTINUE_POLLING, SYNC_STOP_POLLING } from '.. * @param pushManagerFactory optional to build a SyncManager with or without streaming support */ export function syncManagerOnlineFactory( - pollingManagerFactory?: (...args: IPollingManagerFactoryParams) => IPollingManager, - pushManagerFactory?: (...args: IPushManagerFactoryParams) => IPushManager | undefined + pollingManagerFactory?: (params: ISyncManagerFactoryParams) => IPollingManager, + pushManagerFactory?: (params: ISyncManagerFactoryParams, pollingManager: IPollingManager) => IPushManager | undefined, ): (params: ISyncManagerFactoryParams) => ISyncManagerCS { /** * SyncManager factory for modular SDK */ - return function ({ - settings, - platform, - splitApi, - storage, - readiness - }: ISyncManagerFactoryParams): ISyncManagerCS { + return function (params: ISyncManagerFactoryParams): ISyncManagerCS { - const log = settings.log; + const { settings, settings: { log, streamingEnabled } } = params; /** Polling Manager */ - const pollingManager = pollingManagerFactory && pollingManagerFactory(splitApi, storage, readiness, settings); + const pollingManager = pollingManagerFactory && pollingManagerFactory(params); /** Push Manager */ - const pushManager = settings.streamingEnabled && pollingManager && pushManagerFactory ? - pushManagerFactory(pollingManager, storage, readiness, splitApi.fetchAuth, platform, settings) : + const pushManager = streamingEnabled && pollingManager && pushManagerFactory ? + pushManagerFactory(params, pollingManager) : undefined; /** Submitter Manager */ // It is not inyected as push and polling managers, because at the moment it is required - const submitter = submitterManagerFactory(settings, storage, splitApi); - + const submitter = submitterManagerFactory(params); /** Sync Manager logic */ function startPolling() { - if (!pollingManager!.isRunning()) { + if (pollingManager!.isRunning()) { + log.info(SYNC_CONTINUE_POLLING); + } else { log.info(SYNC_START_POLLING); pollingManager!.start(); - } else { - log.info(SYNC_CONTINUE_POLLING); } } @@ -75,12 +69,18 @@ export function syncManagerOnlineFactory( let startFirstTime = true; // flag to distinguish calling the `start` method for the first time, to support pausing and resuming the synchronization return { + // Exposed for fine-grained control of synchronization. + // E.g.: user consent, app state changes (Page hide, Foreground/Background, Online/Offline). + pollingManager, pushManager, + submitter, /** * Method used to start the syncManager for the first time, or resume it after being stopped. */ start() { + running = true; + // start syncing splits and segments if (pollingManager) { if (pushManager) { @@ -96,21 +96,21 @@ export function syncManagerOnlineFactory( } // start periodic data recording (events, impressions, telemetry). - submitter && submitter.start(); - running = true; + if (isConsentGranted(settings)) submitter.start(); }, /** * Method used to stop/pause the syncManager. */ stop() { + running = false; + // stop syncing if (pushManager) pushManager.stop(); if (pollingManager && pollingManager.isRunning()) pollingManager.stop(); // stop periodic data recording (events, impressions, telemetry). - if (submitter) submitter.stop(); - running = false; + submitter.stop(); }, isRunning() { @@ -118,7 +118,7 @@ export function syncManagerOnlineFactory( }, flush() { - if (submitter) return submitter.execute(); + if (isConsentGranted(settings)) return submitter.execute(); else return Promise.resolve(); }, @@ -141,7 +141,7 @@ export function syncManagerOnlineFactory( // of segments since `syncAll` was already executed when starting the main client mySegmentsSyncTask.execute(); } - (pushManager as IPushManagerCS).add(matchingKey, mySegmentsSyncTask); + pushManager.add(matchingKey, mySegmentsSyncTask); } else { if (storage.splits.usesSegments()) mySegmentsSyncTask.start(); } @@ -151,7 +151,7 @@ export function syncManagerOnlineFactory( const mySegmentsSyncTask = (pollingManager as IPollingManagerCS).get(matchingKey); if (mySegmentsSyncTask) { // stop syncing - if (pushManager) (pushManager as IPushManagerCS).remove(matchingKey); + if (pushManager) pushManager.remove(matchingKey); if (mySegmentsSyncTask.isRunning()) mySegmentsSyncTask.stop(); (pollingManager as IPollingManagerCS).remove(matchingKey); diff --git a/src/sync/types.ts b/src/sync/types.ts index 0f0588d9..1a70a5c6 100644 --- a/src/sync/types.ts +++ b/src/sync/types.ts @@ -3,6 +3,7 @@ import { IPlatform } from '../sdkFactory/types'; import { ISplitApi } from '../services/types'; import { IStorageSync } from '../storages/types'; import { ISettings } from '../types'; +import { IPollingManager } from './polling/types'; import { IPushManager } from './streaming/types'; export interface ITask { @@ -43,7 +44,9 @@ export interface ITimeTracker { export interface ISyncManager extends ITask { flush(): Promise, - pushManager?: IPushManager + pushManager?: IPushManager, + pollingManager?: IPollingManager, + submitter?: ISyncTask } export interface ISyncManagerCS extends ISyncManager { diff --git a/src/trackers/__tests__/eventTracker.spec.ts b/src/trackers/__tests__/eventTracker.spec.ts index aa2cd234..d770d899 100644 --- a/src/trackers/__tests__/eventTracker.spec.ts +++ b/src/trackers/__tests__/eventTracker.spec.ts @@ -1,5 +1,5 @@ -import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; import { SplitIO } from '../../types'; +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; import { eventTrackerFactory } from '../eventTracker'; /* Mocks */ @@ -12,30 +12,30 @@ const fakeIntegrationsManager = { handleEvent: jest.fn() }; +const fakeEvent = { + eventTypeId: 'eventTypeId', + trafficTypeName: 'trafficTypeName', + value: 0, + timestamp: Date.now(), + key: 'matchingKey', + properties: { + prop1: 'prop1', + prop2: 0, + } +}; + /* Tests */ describe('Event Tracker', () => { test('Tracker API', () => { expect(typeof eventTrackerFactory).toBe('function'); // The module should return a function which acts as a factory. - const instance = eventTrackerFactory(loggerMock, fakeEventsCache, fakeIntegrationsManager); + const instance = eventTrackerFactory(fullSettings, fakeEventsCache, fakeIntegrationsManager); expect(typeof instance.track).toBe('function'); // The instance should implement the track method. }); - test('Propagate the event into the event cache and integrations manager, and return its result (a boolean or a promise that resolves to boolean)', (done) => { - const fakeEvent = { - eventTypeId: 'eventTypeId', - trafficTypeName: 'trafficTypeName', - value: 0, - timestamp: Date.now(), - key: 'matchingKey', - properties: { - prop1: 'prop1', - prop2: 0, - } - }; - + test('Propagate the event into the event cache and integrations manager, and return its result (a boolean or a promise that resolves to boolean)', async () => { fakeEventsCache.track.mockImplementation((eventData: SplitIO.EventData, size: number) => { if (eventData === fakeEvent) { switch (size) { @@ -46,46 +46,61 @@ describe('Event Tracker', () => { } }); - const tracker = eventTrackerFactory(loggerMock, fakeEventsCache, fakeIntegrationsManager); + const tracker = eventTrackerFactory(fullSettings, fakeEventsCache, fakeIntegrationsManager); const result1 = tracker.track(fakeEvent, 1); expect(fakeEventsCache.track.mock.calls[0]).toEqual([fakeEvent, 1]); // Should be present in the event cache. expect(fakeIntegrationsManager.handleEvent).not.toBeCalled(); // The integration manager handleEvent method should not be executed synchronously. expect(result1).toBe(true); // Should return the value of the event cache. - setTimeout(() => { - expect(fakeIntegrationsManager.handleEvent.mock.calls[0]).toEqual([fakeEvent]); // A copy of the tracked event should be sent to integration manager after the timeout wrapping make it to the queue stack. - expect(fakeIntegrationsManager.handleEvent.mock.calls[0][0]).not.toBe(fakeEvent); // Should not send the original event. + await new Promise(res => setTimeout(res)); + expect(fakeIntegrationsManager.handleEvent.mock.calls[0]).toEqual([fakeEvent]); // A copy of the tracked event should be sent to integration manager after the timeout wrapping make it to the queue stack. + expect(fakeIntegrationsManager.handleEvent.mock.calls[0][0]).not.toBe(fakeEvent); // Should not send the original event. + + const result2 = tracker.track(fakeEvent, 2) as Promise; - const result2 = tracker.track(fakeEvent, 2) as Promise; + expect(fakeEventsCache.track.mock.calls[1]).toEqual([fakeEvent, 2]); // Should be present in the event cache. - expect(fakeEventsCache.track.mock.calls[1]).toEqual([fakeEvent, 2]); // Should be present in the event cache. + let tracked = await result2; + expect(tracked).toBe(false); // Should return the value of the event cache resolved promise. - result2.then(tracked => { - expect(tracked).toBe(false); // Should return the value of the event cache resolved promise. + await new Promise(res => setTimeout(res)); + expect(fakeIntegrationsManager.handleEvent).toBeCalledTimes(1); // Untracked event should not be sent to integration manager. - setTimeout(() => { - expect(fakeIntegrationsManager.handleEvent).toBeCalledTimes(1); // Untracked event should not be sent to integration manager. + const result3 = tracker.track(fakeEvent, 3) as Promise; + + expect(fakeEventsCache.track.mock.calls[2]).toEqual([fakeEvent, 3]); // Should be present in the event cache. + + tracked = await result3; + expect(fakeIntegrationsManager.handleEvent).toBeCalledTimes(1); // Tracked event should not be sent to integration manager synchronously + expect(tracked).toBe(true); // Should return the value of the event cache resolved promise. + + await new Promise(res => setTimeout(res)); + expect(fakeIntegrationsManager.handleEvent.mock.calls[1]).toEqual([fakeEvent]); // A copy of tracked event should be sent to integration manager after the timeout wrapping make it to the queue stack. + expect(fakeIntegrationsManager.handleEvent.mock.calls[1][0]).not.toBe(fakeEvent); // Should not send the original event. + + }); - const result3 = tracker.track(fakeEvent, 3) as Promise; + test('Should track or not events depending on user consent status', () => { + const settings = { ...fullSettings }; + const fakeEventsCache = { track: jest.fn(() => true) }; - expect(fakeEventsCache.track.mock.calls[2]).toEqual([fakeEvent, 3]); // Should be present in the event cache. + const tracker = eventTrackerFactory(settings, fakeEventsCache); - result3.then(tracked => { - expect(fakeIntegrationsManager.handleEvent).toBeCalledTimes(1); // Tracked event should not be sent to integration manager synchronously - expect(tracked).toBe(true); // Should return the value of the event cache resolved promise. + expect(tracker.track(fakeEvent)).toBe(true); + expect(fakeEventsCache.track).toBeCalledTimes(1); // event should be tracked if userConsent is undefined - setTimeout(() => { - expect(fakeIntegrationsManager.handleEvent.mock.calls[1]).toEqual([fakeEvent]); // A copy of tracked event should be sent to integration manager after the timeout wrapping make it to the queue stack. - expect(fakeIntegrationsManager.handleEvent.mock.calls[1][0]).not.toBe(fakeEvent); // Should not send the original event. + settings.userConsent = 'UNKNOWN'; + expect(tracker.track(fakeEvent)).toBe(true); + expect(fakeEventsCache.track).toBeCalledTimes(2); // event should be tracked if userConsent is unknown - done(); - }, 0); - }); + settings.userConsent = 'GRANTED'; + expect(tracker.track(fakeEvent)).toBe(true); + expect(fakeEventsCache.track).toBeCalledTimes(3); // event should be tracked if userConsent is granted - }, 0); - }); - }, 0); + settings.userConsent = 'DECLINED'; + expect(tracker.track(fakeEvent)).toBe(false); + expect(fakeEventsCache.track).toBeCalledTimes(3); // event should not be tracked if userConsent is declined }); }); diff --git a/src/trackers/__tests__/impressionsTracker.spec.ts b/src/trackers/__tests__/impressionsTracker.spec.ts index fe763d67..0a35db12 100644 --- a/src/trackers/__tests__/impressionsTracker.spec.ts +++ b/src/trackers/__tests__/impressionsTracker.spec.ts @@ -3,48 +3,51 @@ import { ImpressionCountsCacheInMemory } from '../../storages/inMemory/Impressio import { impressionObserverSSFactory } from '../impressionObserver/impressionObserverSS'; import { impressionObserverCSFactory } from '../impressionObserver/impressionObserverCS'; import { ImpressionDTO } from '../../types'; -import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; /* Mocks */ -function generateMocks() { - const fakeImpressionsCache = { - track: jest.fn() - }; - const fakeSettings = { - runtime: { - hostname: 'fake-hostname', - ip: 'fake-ip' - }, - version: 'jest-test', - }; - const fakeListener = { - logImpression: jest.fn() - }; - const fakeIntegrationsManager = { - handleImpression: jest.fn() - }; - - return { - fakeImpressionsCache, fakeSettings, fakeListener, fakeIntegrationsManager - }; -} +const fakeImpressionsCache = { + track: jest.fn() +}; +const fakeListener = { + logImpression: jest.fn() +}; +const fakeIntegrationsManager = { + handleImpression: jest.fn() +}; +const fakeSettings = { + ...fullSettings, + runtime: { + hostname: 'fake-hostname', + ip: 'fake-ip' + }, + version: 'jest-test' +}; +const fakeSettingsWithListener = { + ...fakeSettings, + impressionListener: fakeListener +}; /* Tests */ describe('Impressions Tracker', () => { + beforeEach(() => { + fakeImpressionsCache.track.mockClear(); + fakeListener.logImpression.mockClear(); + fakeIntegrationsManager.handleImpression.mockClear(); + }); + test('Tracker API', () => { expect(typeof impressionsTrackerFactory).toBe('function'); // The module should return a function which acts as a factory. - const { fakeImpressionsCache, fakeSettings } = generateMocks(); - const instance = impressionsTrackerFactory(loggerMock, fakeImpressionsCache, fakeSettings); + const instance = impressionsTrackerFactory(fakeSettings, fakeImpressionsCache); expect(typeof instance.track).toBe('function'); // The instance should implement the track method which will actually track queued impressions. }); test('Should be able to track impressions (in DEBUG mode without Previous Time).', () => { - const { fakeImpressionsCache, fakeSettings } = generateMocks(); - const tracker = impressionsTrackerFactory(loggerMock, fakeImpressionsCache, fakeSettings); + const tracker = impressionsTrackerFactory(fakeSettings, fakeImpressionsCache); const imp1 = { feature: '10', @@ -64,8 +67,7 @@ describe('Impressions Tracker', () => { }); test('Tracked impressions should be sent to impression listener and integration manager when we invoke .track()', (done) => { - const { fakeImpressionsCache, fakeSettings, fakeListener, fakeIntegrationsManager } = generateMocks(); - const tracker = impressionsTrackerFactory(loggerMock, fakeImpressionsCache, fakeSettings, fakeListener, fakeIntegrationsManager); + const tracker = impressionsTrackerFactory(fakeSettingsWithListener, fakeImpressionsCache, fakeIntegrationsManager); const fakeImpression = { feature: 'impression' @@ -135,13 +137,12 @@ describe('Impressions Tracker', () => { } as ImpressionDTO; test('Should track 3 impressions with Previous Time.', () => { - const { fakeImpressionsCache, fakeSettings } = generateMocks(); impression.time = impression2.time = 123456789; impression3.time = 1234567891; const trackers = [ - impressionsTrackerFactory(loggerMock, fakeImpressionsCache, fakeSettings, undefined, undefined, impressionObserverSSFactory()), - impressionsTrackerFactory(loggerMock, fakeImpressionsCache, fakeSettings, undefined, undefined, impressionObserverCSFactory()) + impressionsTrackerFactory(fakeSettings, fakeImpressionsCache, undefined, impressionObserverSSFactory()), + impressionsTrackerFactory(fakeSettings, fakeImpressionsCache, undefined, impressionObserverCSFactory()) ]; expect(fakeImpressionsCache.track).not.toBeCalled(); // storage method should not be called until impressions are tracked. @@ -163,13 +164,12 @@ describe('Impressions Tracker', () => { }); test('Should track 2 impressions in OPTIMIZED mode (providing ImpressionCountsCache).', () => { - const { fakeImpressionsCache, fakeSettings } = generateMocks(); impression.time = Date.now(); impression2.time = Date.now(); impression3.time = Date.now(); const impressionCountsCache = new ImpressionCountsCacheInMemory(); - const tracker = impressionsTrackerFactory(loggerMock, fakeImpressionsCache, fakeSettings, undefined, undefined, impressionObserverCSFactory(), impressionCountsCache); + const tracker = impressionsTrackerFactory(fakeSettings, fakeImpressionsCache, undefined, impressionObserverCSFactory(), impressionCountsCache); expect(fakeImpressionsCache.track).not.toBeCalled(); // cache method should not be called by just creating a tracker @@ -187,4 +187,25 @@ describe('Impressions Tracker', () => { expect(Object.keys(impressionCountsCache.state()).length).toBe(2); }); + test('Should track or not impressions depending on user consent status', () => { + const settings = { ...fullSettings }; + + const tracker = impressionsTrackerFactory(settings, fakeImpressionsCache); + + tracker.track([impression]); + expect(fakeImpressionsCache.track).toBeCalledTimes(1); // impression should be tracked if userConsent is undefined + + settings.userConsent = 'UNKNOWN'; + tracker.track([impression]); + expect(fakeImpressionsCache.track).toBeCalledTimes(2); // impression should be tracked if userConsent is unknown + + settings.userConsent = 'GRANTED'; + tracker.track([impression]); + expect(fakeImpressionsCache.track).toBeCalledTimes(3); // impression should be tracked if userConsent is granted + + settings.userConsent = 'DECLINED'; + tracker.track([impression]); + expect(fakeImpressionsCache.track).toBeCalledTimes(3); // impression should not be tracked if userConsent is declined + }); + }); diff --git a/src/trackers/eventTracker.ts b/src/trackers/eventTracker.ts index b3d63f6e..7ac851a9 100644 --- a/src/trackers/eventTracker.ts +++ b/src/trackers/eventTracker.ts @@ -2,9 +2,10 @@ import { objectAssign } from '../utils/lang/objectAssign'; import { thenable } from '../utils/promise/thenable'; import { IEventsCacheBase } from '../storages/types'; import { IEventsHandler, IEventTracker } from './types'; -import { SplitIO } from '../types'; -import { ILogger } from '../logger/types'; +import { ISettings, SplitIO } from '../types'; import { EVENTS_TRACKER_SUCCESS, ERROR_EVENTS_TRACKER } from '../logger/constants'; +import { CONSENT_DECLINED } from '../utils/constants'; +import { isStorageSync } from './impressionObserver/utils'; /** * Event tracker stores events in cache and pass them to the integrations manager if provided. @@ -13,11 +14,14 @@ import { EVENTS_TRACKER_SUCCESS, ERROR_EVENTS_TRACKER } from '../logger/constant * @param integrationsManager optional event handler used for integrations */ export function eventTrackerFactory( - log: ILogger, + settings: ISettings, eventsCache: IEventsCacheBase, integrationsManager?: IEventsHandler ): IEventTracker { + const log = settings.log; + const isSync = isStorageSync(settings); + function queueEventsCallback(eventData: SplitIO.EventData, tracked: boolean) { const { eventTypeId, trafficTypeName, key, value, timestamp, properties } = eventData; // Logging every prop would be too much. @@ -44,6 +48,10 @@ export function eventTrackerFactory( return { track(eventData: SplitIO.EventData, size?: number) { + if (settings.userConsent === CONSENT_DECLINED) { + return isSync ? false : Promise.resolve(false); + } + const tracked = eventsCache.track(eventData, size); if (thenable(tracked)) { diff --git a/src/trackers/impressionObserver/utils.ts b/src/trackers/impressionObserver/utils.ts index 7fb73b85..4ecccbe3 100644 --- a/src/trackers/impressionObserver/utils.ts +++ b/src/trackers/impressionObserver/utils.ts @@ -1,4 +1,4 @@ -import { CONSUMER_PARTIAL_MODE, OPTIMIZED, PRODUCER_MODE, STANDALONE_MODE } from '../../utils/constants'; +import { CONSUMER_MODE, CONSUMER_PARTIAL_MODE, OPTIMIZED, PRODUCER_MODE, STANDALONE_MODE } from '../../utils/constants'; import { ISettings } from '../../types'; /** @@ -15,3 +15,10 @@ export function shouldBeOptimized(settings: ISettings) { if (!shouldAddPt(settings)) return false; return settings.sync.impressionsMode === OPTIMIZED ? true : false; } + +/** + * Storage is async if mode is consumer or partial consumer + */ +export function isStorageSync(settings: ISettings) { + return [CONSUMER_MODE, CONSUMER_PARTIAL_MODE].indexOf(settings.mode) === -1 ? true : false; +} diff --git a/src/trackers/impressionsTracker.ts b/src/trackers/impressionsTracker.ts index c7c18e5b..ecd40a5d 100644 --- a/src/trackers/impressionsTracker.ts +++ b/src/trackers/impressionsTracker.ts @@ -5,8 +5,8 @@ import { IImpressionCountsCacheSync, IImpressionsCacheBase } from '../storages/t import { IImpressionsHandler, IImpressionsTracker } from './types'; import { SplitIO, ImpressionDTO, ISettings } from '../types'; import { IImpressionObserver } from './impressionObserver/types'; -import { ILogger } from '../logger/types'; import { IMPRESSIONS_TRACKER_SUCCESS, ERROR_IMPRESSIONS_TRACKER, ERROR_IMPRESSIONS_LISTENER } from '../logger/constants'; +import { CONSENT_DECLINED } from '../utils/constants'; /** * Impressions tracker stores impressions in cache and pass them to the listener and integrations manager if provided. @@ -19,22 +19,21 @@ import { IMPRESSIONS_TRACKER_SUCCESS, ERROR_IMPRESSIONS_TRACKER, ERROR_IMPRESSIO * @param countsCache optional cache to save impressions count. If provided, impressions will be deduped (OPTIMIZED mode) */ export function impressionsTrackerFactory( - log: ILogger, + settings: ISettings, impressionsCache: IImpressionsCacheBase, - - // @TODO consider passing only an optional integrationsManager to handle impressions - { runtime: { ip, hostname }, version }: Pick, - impressionListener?: SplitIO.IImpressionListener, integrationsManager?: IImpressionsHandler, - // if observer is provided, it implies `shouldAddPreviousTime` flag (i.e., if impressions previous time should be added or not) observer?: IImpressionObserver, // if countsCache is provided, it implies `isOptimized` flag (i.e., if impressions should be deduped or not) countsCache?: IImpressionCountsCacheSync ): IImpressionsTracker { + const { log, impressionListener, runtime: { ip, hostname }, version } = settings; + return { track(impressions: ImpressionDTO[], attributes?: SplitIO.Attributes) { + if (settings.userConsent === CONSENT_DECLINED) return; + const impressionsCount = impressions.length; const impressionsToStore: ImpressionDTO[] = []; // Track only the impressions that are going to be stored @@ -85,7 +84,7 @@ export function impressionsTrackerFactory( // integrationsManager.handleImpression does not throw errors if (integrationsManager) integrationsManager.handleImpression(impressionData); - try { // An exception on the listeners should not break the SDK. + try { // @ts-ignore. An exception on the listeners should not break the SDK. if (impressionListener) impressionListener.logImpression(impressionData); } catch (err) { log.error(ERROR_IMPRESSIONS_LISTENER, [err]); diff --git a/src/types.ts b/src/types.ts index 9a94dd45..7d523223 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,6 +54,11 @@ type EventConsts = { * @typedef {string} SDKMode */ export type SDKMode = 'standalone' | 'consumer' | 'localhost' | 'consumer_partial'; +/** + * User consent status. + * @typedef {string} ConsentStatus + */ +export type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; /** * Settings interface. This is a representation of the settings the SDK expose, that's why * most of it's props are readonly. Only features should be rewritten when localhost mode is active. @@ -73,6 +78,7 @@ export interface ISettings { readonly scheduler: { featuresRefreshRate: number, impressionsRefreshRate: number, + impressionsQueueSize: number, metricsRefreshRate: number, segmentsRefreshRate: number, offlineRefreshRate: number, @@ -110,6 +116,7 @@ export interface ISettings { }, readonly log: ILogger readonly impressionListener?: unknown + readonly userConsent?: ConsentStatus } /** * Log levels. @@ -255,6 +262,13 @@ interface INodeBasicSettings extends ISharedSettings { * @default 300 */ impressionsRefreshRate?: number, + /** + * The maximum number of impression items we want to queue. If we queue more values, it will trigger a flush and reset the timer. + * If you use a 0 here, the queue will have no maximum size. + * @property {number} impressionsQueueSize + * @default 30000 + */ + impressionsQueueSize?: number, /** * The SDK sends diagnostic metrics to Split servers. This parameters controls this metric flush period in seconds. * @property {number} metricsRefreshRate @@ -381,6 +395,10 @@ interface IBasicClient extends IStatusInterface { * @returns {Promise} */ destroy(): Promise + + // Whether the client implements the client-side API, i.e, with bound key, (true), or the server-side API (false). + // Exposed for internal purposes only. Not considered part of the public API, and might be renamed eventually. + isClientSide: boolean } /** * Common definitions between SDK instances for different environments interface. @@ -765,6 +783,13 @@ export namespace SplitIO { * @default 60 */ impressionsRefreshRate?: number, + /** + * The maximum number of impression items we want to queue. If we queue more values, it will trigger a flush and reset the timer. + * If you use a 0 here, the queue will have no maximum size. + * @property {number} impressionsQueueSize + * @default 30000 + */ + impressionsQueueSize?: number, /** * The SDK sends diagnostic metrics to Split servers. This parameters controls this metric flush period in seconds. * @property {number} metricsRefreshRate @@ -1139,7 +1164,7 @@ export namespace SplitIO { /** * Removes from client's in memory attributes storage the attribute with the given key * @function removeAttribute - * @param {string} attributeName + * @param {string} attributeName * @returns {boolean} true if attribute was removed and false otherways */ removeAttribute(attributeName: string): boolean, diff --git a/src/utils/__tests__/Backoff.spec.ts b/src/utils/__tests__/Backoff.spec.ts new file mode 100644 index 00000000..7fd5c083 --- /dev/null +++ b/src/utils/__tests__/Backoff.spec.ts @@ -0,0 +1,44 @@ +import { Backoff } from '../Backoff'; + +test('Backoff', (done) => { + + let start = Date.now(); + let backoff: Backoff; + + let alreadyReset = false; + const callback = () => { + const delta = Date.now() - start; + start += delta; + const expectedMillis = Math.min(backoff.baseMillis * Math.pow(2, backoff.attempts - 1), backoff.maxMillis); + + expect(delta > expectedMillis - 20 && delta < expectedMillis + 20).toBe(true); // executes callback at expected time + if (backoff.attempts <= 3) { + backoff.scheduleCall(); + } else { + backoff.reset(); + expect(backoff.attempts).toBe(0); // restarts attempts when `reset` called + expect(backoff.timeoutID).toBe(undefined); // restarts timeoutId when `reset` called + + // init the schedule cycle or finish the test + if (alreadyReset) { + done(); + } else { + alreadyReset = true; + backoff.scheduleCall(); + } + } + }; + + backoff = new Backoff(callback); + expect(backoff.cb).toBe(callback); // contains given callback + expect(backoff.baseMillis).toBe(Backoff.DEFAULT_BASE_MILLIS); // contains default baseMillis + expect(backoff.maxMillis).toBe(Backoff.DEFAULT_MAX_MILLIS); // contains default maxMillis + + const CUSTOM_BASE = 200; + const CUSTOM_MAX = 700; + backoff = new Backoff(callback, CUSTOM_BASE, CUSTOM_MAX); + expect(backoff.baseMillis).toBe(CUSTOM_BASE); // contains given baseMillis + expect(backoff.maxMillis).toBe(CUSTOM_MAX); // contains given maxMillis + + expect(backoff.scheduleCall()).toBe(backoff.baseMillis); // scheduleCall returns the scheduled delay time +}); diff --git a/src/utils/constants/index.ts b/src/utils/constants/index.ts index 4a4d8303..243accd6 100644 --- a/src/utils/constants/index.ts +++ b/src/utils/constants/index.ts @@ -32,3 +32,8 @@ export const STORAGE_MEMORY: StorageType = 'MEMORY'; export const STORAGE_LOCALSTORAGE: StorageType = 'LOCALSTORAGE'; export const STORAGE_REDIS: StorageType = 'REDIS'; export const STORAGE_PLUGGABLE: StorageType = 'PLUGGABLE'; + +// User consent +export const CONSENT_GRANTED = 'GRANTED'; // The user has granted consent for tracking events and impressions +export const CONSENT_DECLINED = 'DECLINED'; // The user has declined consent for tracking events and impressions +export const CONSENT_UNKNOWN = 'UNKNOWN'; // The user has neither granted nor declined consent for tracking events and impressions diff --git a/src/utils/inputValidation/__tests__/apiKey.spec.ts b/src/utils/inputValidation/__tests__/apiKey.spec.ts index 76d18f1f..e9cd1c18 100644 --- a/src/utils/inputValidation/__tests__/apiKey.spec.ts +++ b/src/utils/inputValidation/__tests__/apiKey.spec.ts @@ -8,7 +8,7 @@ const invalidKeys = [ { key: null, msg: ERROR_NULL }, { key: undefined, msg: ERROR_NULL }, { key: () => { }, msg: ERROR_INVALID }, - { key: new Promise(r => r()), msg: ERROR_INVALID }, + { key: new Promise(r => r()), msg: ERROR_INVALID }, { key: Symbol('asd'), msg: ERROR_INVALID }, { key: [], msg: ERROR_INVALID }, { key: true, msg: ERROR_INVALID }, diff --git a/src/utils/inputValidation/__tests__/key.spec.ts b/src/utils/inputValidation/__tests__/key.spec.ts index 74bdb62e..5535d6f8 100644 --- a/src/utils/inputValidation/__tests__/key.spec.ts +++ b/src/utils/inputValidation/__tests__/key.spec.ts @@ -9,7 +9,7 @@ const invalidKeys = [ { key: null, msg: ERROR_NULL }, { key: undefined, msg: ERROR_NULL }, { key: () => { }, msg: ERROR_INVALID }, - { key: new Promise(r => r()), msg: ERROR_INVALID }, + { key: new Promise(r => r()), msg: ERROR_INVALID }, { key: Symbol('asd'), msg: ERROR_INVALID }, { key: [], msg: ERROR_INVALID }, { key: true, msg: ERROR_INVALID }, diff --git a/src/utils/inputValidation/attributes.ts b/src/utils/inputValidation/attributes.ts index 7a06ee90..e9824113 100644 --- a/src/utils/inputValidation/attributes.ts +++ b/src/utils/inputValidation/attributes.ts @@ -6,7 +6,7 @@ import { ERROR_NOT_PLAIN_OBJECT } from '../../logger/constants'; export function validateAttributes(log: ILogger, maybeAttrs: any, method: string): SplitIO.Attributes | undefined | false { // Attributes are optional - if (isObject(maybeAttrs) || maybeAttrs == undefined) // eslint-disable-line eqeqeq + if (maybeAttrs == undefined || isObject(maybeAttrs)) // eslint-disable-line eqeqeq return maybeAttrs; log.error(ERROR_NOT_PLAIN_OBJECT, [method, 'attributes']); @@ -23,5 +23,4 @@ export function validateAttributesDeep(log: ILogger, maybeAttributes: Record { +test('keyParser / if a string is passed as a param, it should return a object', () => { const key = 'some key'; const keyParsed = keyParser(key); @@ -24,7 +23,7 @@ test('keyParser / should return the keys configurations', () => { expect(keyParsed.bucketingKey).toBe(bucketingKey); // matching key should be equal to bucketingKey }); -test('getMatching / if a string is passed as a param it should return a string', () => { +test('getMatching / if a string is passed as a param, it should return a string', () => { const key = 'some key'; const keyParsed = getMatching(key); @@ -33,7 +32,7 @@ test('getMatching / if a string is passed as a param it should return a string', expect(keyParsed).toBe(key); // key parsed should be equal to key }); -test('getMatching / if a object is passed as a param it should return a string', () => { +test('getMatching / if a object is passed as a param, it should return a string', () => { const key = { matchingKey: 'some key', @@ -46,7 +45,7 @@ test('getMatching / if a object is passed as a param it should return a string', expect(keyParsed).toBe(key.matchingKey); // key parsed should be equal to key }); -test('getBucketing / should return undefined if a string is passed as a param and return undefined is set', () => { +test('getBucketing / if a string is passed as a param, it should return undefined', () => { const key = 'simple key'; @@ -55,11 +54,11 @@ test('getBucketing / should return undefined if a string is passed as a param an expect(keyParsed).toBe(undefined); // key parsed should return undefined }); -test('getBucketing / should return undefined if a string is passed as a param and return undefined is set', () => { +test('getBucketing / if a object is passed as a param, it should return the bucketingKey value', () => { - const key = { bucketingKey: 'test_buck_key' }; + const key: any = { bucketingKey: 'test_buck_key' }; - const keyParsed = getBucketing(key as SplitIO.SplitKeyObject); + const keyParsed = getBucketing(key); - expect(keyParsed).toBe('test_buck_key'); // key parsed should return undefined + expect(keyParsed).toBe('test_buck_key'); // key parsed should return the bucketingKey }); diff --git a/src/utils/lang/__tests__/binarySearch.spec.ts b/src/utils/lang/__tests__/binarySearch.spec.ts new file mode 100644 index 00000000..9b7a5510 --- /dev/null +++ b/src/utils/lang/__tests__/binarySearch.spec.ts @@ -0,0 +1,132 @@ +/** +Copyright 2022 Split Software + +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 { binarySearch } from '../binarySearch'; + +test('BINARY SEARCH / given [1,3,5,7,10] as dataset look for several elements', () => { + let searchFor = binarySearch.bind(null, [1, 3, 5, 7, 10]); + let index = undefined; + let value = -1; + + index = searchFor(value); + expect(index).toBe(0); // expected value 0 + + value++; // 0 + index = searchFor(value); + expect(index).toBe(0); // expected value 0 + + value++; // 1 + index = searchFor(value); + expect(index).toBe(0); // expected value 0 + + value++; // 2 + index = searchFor(value); + expect(index).toBe(0); // expected value 0 + + value++; // 3 + index = searchFor(value); + expect(index).toBe(1); // expected value 1 + + value++; // 4 + index = searchFor(value); + expect(index).toBe(1); // expected value 1 + + value++; // 5 + index = searchFor(value); + expect(index).toBe(2); // expected value 2 + + value++; // 6 + index = searchFor(value); + expect(index).toBe(2); // expected value 2 + + value++; // 7 + index = searchFor(value); + expect(index).toBe(3); // expected value 3 + + value++; // 8 + index = searchFor(value); + expect(index).toBe(3); // expected value 3 + + value++; // 9 + index = searchFor(value); + expect(index).toBe(3); // expected value 3 + + value++; // 10 + index = searchFor(value); + expect(index).toBe(4); // expected value 4 + + value++; // 11 + index = searchFor(value); + expect(index).toBe(4); // expected value 4 + + value++; // 12 + index = searchFor(value); + expect(index).toBe(4); // expected value 4 +}); + +test('BINARY SEARCH / run test using integer keys', () => { + const KEYS = [ + 1000, 1500, 2250, 3375, 5063, + 7594, 11391, 17086, 25629, 38443, + 57665, 86498, 129746, 194620, 291929, + 437894, 656841, 985261, 1477892, 2216838, + 3325257, 4987885, 7481828 + ]; + + let searchFor = binarySearch.bind(null, KEYS); + + let index = searchFor(10); + expect(index).toBe(0); // expected value 0 + + index = searchFor(1001); + expect(index).toBe(0); // expected value 0 + + index = searchFor(1499); + expect(index).toBe(0); // expected value 0 + + index = searchFor(1500); + expect(index).toBe(1); // expected value 1 + + index = searchFor(2200); + expect(index).toBe(1); // expected value 1 + + index = searchFor(2251); + expect(index).toBe(2); // expected value 2 +}); + +test('BINARY SEARCH / run test using float keys', () => { + const KEYS = [ + 1, 1.5, 2.25, 3.38, 5.06, 7.59, 11.39, 17.09, 25.63, 38.44, + 57.67, 86.5, 129.75, 194.62, 291.93, 437.89, 656.84, 985.26, 1477.89, + 2216.84, 3325.26, 4987.89, 7481.83 + ]; + + let searchFor = binarySearch.bind(null, KEYS); + + let index = searchFor(3.38); + expect(index).toBe(3); // expected value 3 + + index = searchFor(6); + expect(index).toBe(4); // expected value 4 + + index = searchFor(500.55); + expect(index).toBe(15); // expected value 15 + + index = searchFor(7481.83); + expect(index).toBe(22); // expected value 22 + + index = searchFor(80000); + expect(index).toBe(22); // expected value 22 +}); diff --git a/src/utils/lang/__tests__/index.spec.ts b/src/utils/lang/__tests__/index.spec.ts index 613ad573..ae502e86 100644 --- a/src/utils/lang/__tests__/index.spec.ts +++ b/src/utils/lang/__tests__/index.spec.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import vm from 'vm'; import { startsWith, endsWith, @@ -18,11 +19,11 @@ import { toNumber, forOwn, groupBy, - shallowClone, isBoolean } from '../index'; import { getFnName } from '../getFnName'; import { getGlobal } from '../getGlobal'; +import { objectAssign } from '../objectAssign'; test('LANG UTILS / startsWith', () => { expect(startsWith('myStr', 'myS')).toBe(true); @@ -231,27 +232,46 @@ test('LANG UTILS / isIntegerNumber', () => { test('LANG UTILS / isObject', () => { // positive - expect(isObject({})).toBe(true); // Should return true for map objects. - expect(isObject({ a: true })).toBe(true); // Should return true for map objects. - expect(isObject(new Object())).toBe(true); // Should return true for map objects. - expect(isObject(Object.create({}))).toBe(true); // Should return true for map objects. - expect(isObject(Object.create(Object.prototype))).toBe(true); // Should return true for map objects. + expect(isObject({})).toBe(true); // Should return true for plain objects (objects created by the Object built-in constructor). + expect(isObject({ a: true })).toBe(true); // Should return true for plain objects. + expect(isObject(new Object())).toBe(true); // Should return true for plain objects. + expect(isObject(Object.create({}))).toBe(true); // Should return true for plain objects. + expect(isObject(Object.create(Object.prototype))).toBe(true); // Should return true for plain objects. // negative - expect(isObject([])).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(() => { })).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(true)).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(false)).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(null)).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(undefined)).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(1)).toBe(false); // Should return false for anything that is not a map object. - expect(isObject('asd')).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(function () { })).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(Symbol('test'))).toBe(false); // Should return false for anything that is not a map object. - expect(isObject(new Promise(res => res()))).toBe(false); // Should return false for anything that is not a map object. + expect(isObject([])).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(() => { })).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(true)).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(false)).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(null)).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(undefined)).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(1)).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject('asd')).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(function () { })).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(Symbol('test'))).toBe(false); // Should return false for anything that is not a plain object. + expect(isObject(new Promise(res => res()))).toBe(false); // Should return false for anything that is not a plain object. // Object.create(null) creates an object with no prototype which may be tricky to handle. Filtering that out too. - expect(isObject(Object.create(null))).toBe(false); // Should return false for anything that is not a map object. - + expect(isObject(Object.create(null))).toBe(false); // Should return false for anything that is not a plain object. + + // validate on a different VM context + const ctx = vm.createContext({ isObject }); + vm.runInContext(` + var positives = + isObject({}) && + isObject({ a: true }) && + isObject(new Object()) && + isObject(Object.create({})) && + isObject(Object.create(Object.prototype)); + var negatives = + isObject([]) || + isObject(() => { }) || + isObject(null) || + isObject(undefined) || + isObject(new Promise(res => res())) || + isObject(Object.create(null)); + `, ctx); + expect(ctx.positives).toBe(true); + expect(ctx.negatives).toBe(false); }); test('LANG UTILS / uniqueId', () => { @@ -529,7 +549,7 @@ test('LANG UTILS / getFnName', () => { }); -test('LANG UTILS / shallowClone', () => { +test('LANG UTILS / objectAssign', () => { const toClone = { aProperty: 1, another: 'two', @@ -539,7 +559,7 @@ test('LANG UTILS / shallowClone', () => { bool: true }; - const clone = shallowClone(toClone); + const clone = objectAssign({}, toClone); expect(clone).toEqual(toClone); // The structure of the shallow clone should be the same since references are copied too. expect(clone).not.toBe(toClone); // But the reference to the object itself is differente since it is a clone @@ -558,4 +578,3 @@ test('LANG UTILS / isBoolean', () => { [true, false].forEach(val => expect(isBoolean(val)).toBe(true)); }); - diff --git a/src/utils/lang/__tests__/maps.spec.ts b/src/utils/lang/__tests__/maps.spec.ts new file mode 100644 index 00000000..02312d50 --- /dev/null +++ b/src/utils/lang/__tests__/maps.spec.ts @@ -0,0 +1,16 @@ +import { __getMapConstructor, MapPoly } from '../maps'; + +test('__getMapConstructor', () => { + + // should return global Map constructor if available + expect(__getMapConstructor()).toBe(global.Map); + + const originalMap = global.Map; // @ts-ignore + global.Map = undefined; // overwrite global Map + + // should return Map polyfill if global Map constructor is not available + expect(__getMapConstructor()).toBe(MapPoly); + + global.Map = originalMap; // restore original global Map + +}); diff --git a/src/utils/lang/index.ts b/src/utils/lang/index.ts index 340d3ccc..bd7c7a51 100644 --- a/src/utils/lang/index.ts +++ b/src/utils/lang/index.ts @@ -89,7 +89,7 @@ export function get(obj: any, prop: any, val: any): any { /** * Parses an array into a map of different arrays, grouping by the specified prop value. */ -export function groupBy >(source: T[], prop: string): Record { +export function groupBy>(source: T[], prop: string): Record { const map: Record = {}; if (Array.isArray(source) && isString(prop)) { @@ -151,10 +151,14 @@ export function isNaNNumber(val: any): boolean { } /** - * Validates if a value is an object with the Object prototype (map object). + * Validates if a value is an object created by the Object constructor (plain object). + * It checks `constructor.name` to avoid false negatives when validating values on a separate VM context, which has its own global built-ins. */ -export function isObject(obj: any): boolean { - return obj !== null && typeof obj === 'object' && obj.constructor === Object; +export function isObject(obj: any) { + return obj !== null && typeof obj === 'object' && ( + obj.constructor === Object || + (obj.constructor != null && obj.constructor.name === 'Object') + ); } /** @@ -164,6 +168,13 @@ export function isString(val: any): val is string { return typeof val === 'string' || val instanceof String; } +/** + * String sanitizer. Returns the provided value converted to uppercase if it is a string. + */ +export function stringToUpperCase(val: any) { + return isString(val) ? val.toUpperCase() : val; +} + /** * Deep copy version of Object.assign using recursion. * There are some assumptions here. It's for internal use and we don't need verbose errors @@ -194,20 +205,6 @@ export function merge(target: { [key: string]: any }, source: { [key: string]: a return res; } -/** - * Shallow clone an object - */ -export function shallowClone(obj: any): any { - const keys = Object.keys(obj); - const output: Record = {}; - - for (let i = 0; i < keys.length; i++) { - output[keys[i]] = obj[keys[i]]; - } - - return output; -} - /** * Checks if the target string starts with the sub string. */ diff --git a/src/utils/lang/maps.ts b/src/utils/lang/maps.ts index 25461615..a0f09cff 100644 --- a/src/utils/lang/maps.ts +++ b/src/utils/lang/maps.ts @@ -79,4 +79,18 @@ interface IMapConstructor { readonly prototype: IMap; } -export const _Map: IMapConstructor = typeof Map !== 'undefined' ? Map : MapPoly; +/** + * return the Map constructor to use. If native Map is not available or it doesn't support the required features (e.g., IE11), + * a ponyfill with minimal features is returned instead. + * + * Exported for testing purposes only. + */ +export function __getMapConstructor(): IMapConstructor { + // eslint-disable-next-line compat/compat + if (typeof Array.from === 'function' && typeof Map === 'function' && Map.prototype && Map.prototype.values) { + return Map; + } + return MapPoly; +} + +export const _Map = __getMapConstructor(); diff --git a/src/utils/murmur3/__tests__/legacy.spec.ts b/src/utils/murmur3/__tests__/legacy.spec.ts new file mode 100644 index 00000000..39ad41cb --- /dev/null +++ b/src/utils/murmur3/__tests__/legacy.spec.ts @@ -0,0 +1,21 @@ +// @ts-nocheck +import * as utils from '../legacy'; +import csv from 'csv-streamify'; +import fs from 'fs'; + +test('ENGINE / validate hashing behavior using sample data', (done) => { + let parser = csv({ objectMode: false }); + + parser.on('data', (line) => { + let [seed, key, hash, bucket] = JSON.parse(line.toString('utf8').trim()); + + seed = parseInt(seed, 10); + hash = parseInt(hash, 10); + bucket = parseInt(bucket, 10); + + expect(utils.hash(key, seed)).toBe(hash); // matching using int32 hash value + expect(utils.bucket(key, seed)).toBe(bucket); // matching using int32 bucket value + }).on('end', done); + + fs.createReadStream(require.resolve('./mocks/small-data.csv')).pipe(parser); +}); diff --git a/src/utils/murmur3/legacy.ts b/src/utils/murmur3/legacy.ts new file mode 100644 index 00000000..1797fb47 --- /dev/null +++ b/src/utils/murmur3/legacy.ts @@ -0,0 +1,48 @@ +// Deprecated hashing function, used for split bucketing. Replaced by murmur3 + +// +// JAVA reference implementation for the hashing function. +// +// int h = 0; +// for (int i = 0; i < key.length(); i++) { +// h = 31 * h + key.charAt(i); +// } +// return h ^ seed; // XOR the hash and seed +// + +function ToInteger(x: number) { + x = Number(x); + return x < 0 ? Math.ceil(x) : Math.floor(x); +} + +function modulo(a: number, b: number) { + return a - Math.floor(a / b) * b; +} + +function ToUint32(x: number) { + return modulo(ToInteger(x), Math.pow(2, 32)); +} + +function ToInt32(x: number) { + let uint32 = ToUint32(x); + + if (uint32 >= Math.pow(2, 31)) { + return uint32 - Math.pow(2, 32); + } else { + return uint32; + } +} + +export function hash(str: string, seed: number): number { + let h = 0; + + for (let i = 0; i < str.length; i++) { + h = ToInt32(ToInt32(31 * h) + str.charCodeAt(i)); + } + + return ToInt32(h ^ seed); +} + +export function bucket(str: string, seed: number): number { + return Math.abs(hash(str, seed) % 100) + 1; +} diff --git a/src/utils/promise/__tests__/thenable.spec.ts b/src/utils/promise/__tests__/thenable.spec.ts new file mode 100644 index 00000000..82dd9ff9 --- /dev/null +++ b/src/utils/promise/__tests__/thenable.spec.ts @@ -0,0 +1,22 @@ +import { thenable } from '../thenable'; + +test('Promise utils / thenable', () => { + const prom = new Promise(() => { }); + const promResolved = Promise.resolve(); + const promRejected = Promise.reject(); + const thenableThing = { then: () => { } }; + const nonThenableThing = { then: 'not a function' }; + + expect(thenable(prom)).toBe(true); // Promises and thenable-like objects should pass the test. + expect(thenable(promResolved)).toBe(true); // Promises and thenable-like objects should pass the test. + expect(thenable(promRejected)).toBe(true); // Promises and thenable-like objects should pass the test. + expect(thenable(thenableThing)).toBe(true); // Promises and thenable-like objects should pass the test. + + expect(thenable(nonThenableThing)).toBe(false); // Non thenable objects should fail the test. + expect(thenable('string')).toBe(false); // Non thenable objects should fail the test. + expect(thenable(123)).toBe(false); // Non thenable objects should fail the test. + expect(thenable({})).toBe(false); // Non thenable objects should fail the test. + expect(thenable({ catch: () => { } })).toBe(false); // Non thenable objects should fail the test. + expect(thenable([prom, promResolved])).toBe(false); // Non thenable objects should fail the test. + expect(thenable(() => { })).toBe(false); // Non thenable objects should fail the test. +}); diff --git a/src/utils/promise/__tests__/timeout.spec.ts b/src/utils/promise/__tests__/timeout.spec.ts new file mode 100644 index 00000000..5ad9a548 --- /dev/null +++ b/src/utils/promise/__tests__/timeout.spec.ts @@ -0,0 +1,76 @@ +import { timeout } from '../timeout'; + +const baseTimeoutInMs = 20; +const resolutionValue = 'random_Value'; + +test('Promise utils / timeout - What happens in the event of a timeout or no timeout at all', async () => { + const prom = new Promise(() => { }); + + expect(timeout(0, prom)).toBe(prom); // If we set the timeout with a value less than 1, we just get the original promise (no timeout). + expect(timeout(-1, prom)).toBe(prom); // If we set the timeout with a value less than 1, we just get the original promise (no timeout). + + prom.then( + () => expect('This should not execute').toBeFalsy(), + () => expect('This should execute on timeout expiration.') + ); + + const ts = Date.now(); + const wrapperProm = timeout(baseTimeoutInMs, prom); + + expect(wrapperProm).not.toBe(prom); // If we actually set a timeout it should return a wrapping promise. + + try { + // This should be rejected after 10ms + await wrapperProm; + expect('Should not execute').toBeFalsy(); + } catch (error: any) { + // The promise was rejected not resolved. Give it an error margin of 10ms since it's not predictable + expect((Date.now() - ts) < baseTimeoutInMs + 20).toBe(true); // The timeout should have rejected the promise. + expect(error.message).toMatch(/^Operation timed out because it exceeded the configured time limit of/); // The timeout should have rejected the promise with a Split Timeout Error. + } +}); + +test('Promise utils / timeout - What happens if the promise resolves before the timeout.', async () => { + let promiseResolver: any = null; + const prom = new Promise(res => { promiseResolver = res; }); + const wrapperProm = timeout(baseTimeoutInMs * 100, prom); + + expect(wrapperProm).not.toBe(prom); // If we actually set a timeout it should return a wrapping promise. + + setTimeout(() => { + // Resolve the promise before the timeout + promiseResolver(resolutionValue); + }, baseTimeoutInMs * 10); + + // This one should not reject but be resolved + try { + // await prom; + const result = await wrapperProm; + + expect(result).toBe(resolutionValue); // The wrapper should resolve to the same value the original promise resolves. + } catch (error) { + expect('Should not execute').toBeFalsy(); + } +}); + +test('Promise utils / timeout - What happens if the promise rejects before the timeout.', async () => { + let promiseRejecter: any = null; + const prom = new Promise((res, rej) => { promiseRejecter = rej; }); + const wrapperProm = timeout(baseTimeoutInMs * 100, prom); + + expect(wrapperProm).not.toBe(prom); // If we actually set a timeout it should return a wrapping promise. + + setTimeout(() => { + // Reject the promise before the timeout + promiseRejecter(resolutionValue); + }, baseTimeoutInMs * 10); + + // This one should not resolve but be rejected + try { + await wrapperProm; + + expect('Should not execute').toBeFalsy(); + } catch (error) { + expect(error).toBe(resolutionValue); // The wrapper should reject to the same error than the original promise. + } +}); diff --git a/src/utils/promise/__tests__/wrapper.spec.ts b/src/utils/promise/__tests__/wrapper.spec.ts new file mode 100644 index 00000000..ca0d418a --- /dev/null +++ b/src/utils/promise/__tests__/wrapper.spec.ts @@ -0,0 +1,162 @@ +// @ts-nocheck +import { promiseWrapper } from '../wrapper'; + +test('Promise utils / promise wrapper', function (done) { + expect.assertions(58); // number of passHandler, passHandlerFinally, passHandlerWithThrow and `hasOnFulfilled` asserts + + const value = 'value'; + const failHandler = (val) => { done.fail(val); }; + const passHandler = (val) => { expect(val).toBe(value); return val; }; + const passHandlerFinally = (val) => { expect(val).toBeUndefined(); }; + const passHandlerWithThrow = (val) => { expect(val).toBe(value); throw val; }; + const createResolvedPromise = () => new Promise((res) => { setTimeout(() => { res(value); }, 100); }); + const createRejectedPromise = () => new Promise((_, rej) => { setTimeout(() => { rej(value); }, 100); }); + + // resolved promises + let wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(false); + + wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise.then(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise.finally(passHandlerFinally); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise.then(passHandler, failHandler).finally(passHandlerFinally); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise.then(passHandler).catch(failHandler).finally(passHandlerFinally); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise.then(passHandler).catch(failHandler).then(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise.then(passHandler).then(passHandler).catch(failHandler).finally(passHandlerFinally).then(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise.then(passHandler).then(passHandlerWithThrow).catch(passHandler).then(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + const wrappedPromise2 = promiseWrapper(createResolvedPromise(), failHandler); + wrappedPromise2.then(() => { + wrappedPromise2.then(passHandler); + }); + expect(wrappedPromise2.hasOnFulfilled()).toBe(true); + + Promise.all([ + promiseWrapper(createResolvedPromise(), failHandler), + promiseWrapper(createResolvedPromise(), failHandler)] + ).then((val) => { expect(val).toEqual([value, value]); }); + + // rejected promises + wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(false); + + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.catch(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(false); + + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.catch(passHandler).then(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(false); + + // caveat: setting an `onFinally` handler as the first handler, requires an `onRejected` handler if promise is rejected + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.finally(passHandlerFinally).catch(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); + wrappedPromise.then(undefined, passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(false); + + wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); + wrappedPromise.then(failHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.then(failHandler).then(failHandler).catch(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createRejectedPromise(), passHandler); + wrappedPromise.then(failHandler).then(failHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.then(failHandler, passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.then(failHandler).catch(passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.then(failHandler).then(failHandler, passHandler); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + wrappedPromise = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise.then(failHandler).catch(passHandler).then(passHandler).finally(passHandlerFinally); + expect(wrappedPromise.hasOnFulfilled()).toBe(true); + + const wrappedPromise3 = promiseWrapper(createRejectedPromise(), failHandler); + wrappedPromise3.catch(() => { + wrappedPromise3.catch(passHandler); + }); + expect(wrappedPromise3.hasOnFulfilled()).toBe(false); + + Promise.all([ + promiseWrapper(createResolvedPromise(), failHandler), + promiseWrapper(createRejectedPromise(), failHandler)]).catch(passHandler); + + setTimeout(() => { + done(); + }, 1000); + +}); + +test('Promise utils / promise wrapper: async/await', async function () { + + expect.assertions(8); // number of passHandler, passHandlerWithThrow and passHandlerFinally + + const value = 'value'; + const failHandler = (val) => { throw val; }; + const passHandler = (val) => { expect(val).toBe(value); return val; }; + const passHandlerFinally = (val) => { expect(val).toBeUndefined(); }; + const passHandlerWithThrow = (val) => { expect(val).toBe(value); throw val; }; + const createResolvedPromise = () => new Promise((res) => { res(value); }); + const createRejectedPromise = () => new Promise((res, rej) => { rej(value); }); + + try { + const result = await promiseWrapper(createResolvedPromise(), failHandler); + passHandler(result); + } catch (result) { + failHandler(result); + } finally { + passHandlerFinally(); + } + + try { + const result = await promiseWrapper(createRejectedPromise(), failHandler); + failHandler(result); + } catch (result) { + passHandler(result); + } + + let result; + try { + result = await promiseWrapper(createResolvedPromise(), failHandler); + passHandler(result); + passHandlerWithThrow(result); + } catch (error) { + result = passHandler(error); + } finally { + passHandlerFinally(); + } + passHandler(result); +}); diff --git a/src/utils/promise/timeout.ts b/src/utils/promise/timeout.ts index 9d3c12ee..5b8bebd7 100644 --- a/src/utils/promise/timeout.ts +++ b/src/utils/promise/timeout.ts @@ -3,7 +3,7 @@ export function timeout(ms: number, promise: Promise): Promise { return new Promise((resolve, reject) => { const tid = setTimeout(() => { - reject(new Error(`Operation timed out because it exceeded the configured time limit of ${ms}ms.`)); + reject(new Error(`Operation timed out because it exceeded the configured time limit of ${ms} ms.`)); }, ms); promise.then((res) => { diff --git a/src/utils/settingsValidation/__tests__/index.spec.ts b/src/utils/settingsValidation/__tests__/index.spec.ts index be0087b5..3f799970 100644 --- a/src/utils/settingsValidation/__tests__/index.spec.ts +++ b/src/utils/settingsValidation/__tests__/index.spec.ts @@ -19,7 +19,8 @@ const minimalSettingsParams = { version: 'javascript-test', }, runtime: () => ({ ip: false, hostname: false } as ISettings['runtime']), - logger: () => (loggerMock as ISettings['log']) + logger: () => (loggerMock as ISettings['log']), + consent: () => undefined }; describe('settingsValidation', () => { @@ -188,6 +189,56 @@ describe('settingsValidation', () => { expect(settings.integrations).toBe(integrationsValidatorResult); expect(integrationsValidatorMock).toBeCalledWith(settings); }); + + test('validates and sanitizes key and traffic type in client-side', () => { + const clientSideValidationParams = { ...minimalSettingsParams, acceptKey: true, acceptTT: true }; + + const samples = [{ + key: ' valid-key ', settingsKey: 'valid-key', // key string is trimmed + trafficType: 'VALID-TT', settingsTrafficType: 'valid-tt', // TT is converted to lowercase + }, { + key: undefined, settingsKey: false, // undefined key is not valid in client-side + trafficType: undefined, settingsTrafficType: undefined, + }, { + key: null, settingsKey: false, + trafficType: null, settingsTrafficType: false, + }, { + key: true, settingsKey: false, + trafficType: true, settingsTrafficType: false, + }, { + key: 1.5, settingsKey: '1.5', // finite number as key is parsed + trafficType: 100, settingsTrafficType: false, + }, { + key: { matchingKey: 100, bucketingKey: ' BUCK ' }, settingsKey: { matchingKey: '100', bucketingKey: 'BUCK' }, + trafficType: {}, settingsTrafficType: false, + }]; + + samples.forEach(({ key, trafficType, settingsKey, settingsTrafficType }) => { + const settings = settingsValidation({ + core: { + authorizationKey: 'dummy token', + key, + trafficType + } + }, clientSideValidationParams); + + expect(settings.core.key).toEqual(settingsKey); + expect(settings.core.trafficType).toEqual(settingsTrafficType); + }); + }); + + test('validates and sanitizes key, while traffic type is ignored', () => { + const settings = settingsValidation({ + core: { + authorizationKey: 'dummy token', + key: true, + trafficType: true + } + }, { ...minimalSettingsParams, acceptKey: true }); + + expect(settings.core.key).toEqual(false); // key is validated + expect(settings.core.trafficType).toEqual(true); // traffic type is ignored + }); }); test('SETTINGS / urls should be correctly assigned', () => { diff --git a/src/utils/settingsValidation/__tests__/settings.mocks.ts b/src/utils/settingsValidation/__tests__/settings.mocks.ts index 80aacabf..ff160297 100644 --- a/src/utils/settingsValidation/__tests__/settings.mocks.ts +++ b/src/utils/settingsValidation/__tests__/settings.mocks.ts @@ -52,6 +52,7 @@ export const fullSettings: ISettings = { offlineRefreshRate: 1, eventsPushRate: 1, eventsQueueSize: 1, + impressionsQueueSize: 1, pushRetryBackoffBase: 1 }, startup: { @@ -65,7 +66,7 @@ export const fullSettings: ISettings = { integrations: [() => { }], // A no-op integration mode: 'standalone', debug: false, - streamingEnabled: false, + streamingEnabled: true, sync: { splitFilters: [], impressionsMode: 'OPTIMIZED', @@ -87,7 +88,17 @@ export const fullSettings: ISettings = { auth: 'auth', streaming: 'streaming' }, - log: loggerMock + log: loggerMock, + userConsent: undefined +}; + +export const fullSettingsServerSide = { + ...fullSettings, + core: { + ...fullSettings.core, + key: undefined, + }, + features: '.split' }; export const settingsSplitApi = { diff --git a/src/utils/settingsValidation/consent.ts b/src/utils/settingsValidation/consent.ts new file mode 100644 index 00000000..98b4112b --- /dev/null +++ b/src/utils/settingsValidation/consent.ts @@ -0,0 +1,16 @@ +import { ERROR_INVALID_CONFIG_PARAM } from '../../logger/constants'; +import { ILogger } from '../../logger/types'; +import { ConsentStatus } from '../../types'; +import { CONSENT_DECLINED, CONSENT_GRANTED, CONSENT_UNKNOWN } from '../constants'; +import { stringToUpperCase } from '../lang'; + +const userConsentValues = [CONSENT_DECLINED, CONSENT_GRANTED, CONSENT_UNKNOWN]; + +export function validateConsent({ userConsent, log }: { userConsent?: any, log: ILogger }): ConsentStatus { + userConsent = stringToUpperCase(userConsent); + + if (userConsentValues.indexOf(userConsent) > -1) return userConsent; + + log.error(ERROR_INVALID_CONFIG_PARAM, ['userConsent', userConsentValues, CONSENT_GRANTED]); + return CONSENT_GRANTED; +} diff --git a/src/utils/settingsValidation/impressionsMode.ts b/src/utils/settingsValidation/impressionsMode.ts index ddcda23d..9fdc3e09 100644 --- a/src/utils/settingsValidation/impressionsMode.ts +++ b/src/utils/settingsValidation/impressionsMode.ts @@ -1,14 +1,14 @@ -import { ERROR_INVALID_IMPRESSIONS_MODE } from '../../logger/constants'; +import { ERROR_INVALID_CONFIG_PARAM } from '../../logger/constants'; import { ILogger } from '../../logger/types'; import { SplitIO } from '../../types'; import { DEBUG, OPTIMIZED } from '../constants'; +import { stringToUpperCase } from '../lang'; -export function validImpressionsMode(log: ILogger, impressionsMode: string): SplitIO.ImpressionsMode { - impressionsMode = impressionsMode.toUpperCase(); - if ([DEBUG, OPTIMIZED].indexOf(impressionsMode) === -1) { - log.error(ERROR_INVALID_IMPRESSIONS_MODE, [[DEBUG, OPTIMIZED], OPTIMIZED]); - impressionsMode = OPTIMIZED; - } +export function validImpressionsMode(log: ILogger, impressionsMode: any): SplitIO.ImpressionsMode { + impressionsMode = stringToUpperCase(impressionsMode); - return impressionsMode as SplitIO.ImpressionsMode; + if ([DEBUG, OPTIMIZED].indexOf(impressionsMode) > -1) return impressionsMode; + + log.error(ERROR_INVALID_CONFIG_PARAM, ['impressionsMode', [DEBUG, OPTIMIZED], OPTIMIZED]); + return OPTIMIZED; } diff --git a/src/utils/settingsValidation/index.ts b/src/utils/settingsValidation/index.ts index e10965a0..5bb1125b 100644 --- a/src/utils/settingsValidation/index.ts +++ b/src/utils/settingsValidation/index.ts @@ -5,6 +5,8 @@ import { STANDALONE_MODE, OPTIMIZED, LOCALHOST_MODE } from '../constants'; import { validImpressionsMode } from './impressionsMode'; import { ISettingsValidationParams } from './types'; import { ISettings } from '../../types'; +import { validateKey } from '../inputValidation/key'; +import { validateTrafficType } from '../inputValidation/trafficType'; const base = { // Define which kind of object you want to retrieve from SplitFactory @@ -38,6 +40,8 @@ const base = { eventsPushRate: 60, // how many events will be queued before flushing eventsQueueSize: 500, + // how many impressions will be queued before flushing + impressionsQueueSize: 30000, // backoff base seconds to wait before re attempting to connect to push notifications pushRetryBackoffBase: 1, }, @@ -78,11 +82,6 @@ const base = { localhostMode: undefined }, - runtime: { - ip: false, - hostname: false - }, - // Logger log: undefined }; @@ -100,7 +99,7 @@ function fromSecondsToMillis(n: number) { */ export function settingsValidation(config: unknown, validationParams: ISettingsValidationParams) { - const { defaults, runtime, storage, integrations, logger, localhost } = validationParams; + const { defaults, runtime, storage, integrations, logger, localhost, consent } = validationParams; // creates a settings object merging base, defaults and config objects. const withDefaults = merge({}, base, defaults, config) as ISettings; @@ -132,14 +131,30 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV // @ts-ignore, modify readonly prop if (storage) withDefaults.storage = storage(withDefaults); - // Although `key` is mandatory according to TS declaration files, it can be omitted in LOCALHOST mode. In that case, the value `localhost_key` is used. - if (withDefaults.mode === LOCALHOST_MODE && withDefaults.core.key === undefined) { - withDefaults.core.key = 'localhost_key'; + // Validate key and TT (for client-side) + if (validationParams.acceptKey) { + const maybeKey = withDefaults.core.key; + // Although `key` is required in client-side, it can be omitted in LOCALHOST mode. In that case, the value `localhost_key` is used. + if (withDefaults.mode === LOCALHOST_MODE && maybeKey === undefined) { + withDefaults.core.key = 'localhost_key'; + } else { + // Keeping same behaviour than JS SDK: if settings key or TT are invalid, + // `false` value is used as binded key/TT of the default client, which leads to some issues. + // @ts-ignore, @TODO handle invalid keys as a non-recoverable error? + withDefaults.core.key = validateKey(log, maybeKey, 'Client instantiation'); + } + + if (validationParams.acceptTT) { + const maybeTT = withDefaults.core.trafficType; + if (maybeTT !== undefined) { // @ts-ignore + withDefaults.core.trafficType = validateTrafficType(log, maybeTT, 'Client instantiation'); + } + } } // Current ip/hostname information // @ts-ignore, modify readonly prop - if (runtime) withDefaults.runtime = runtime(withDefaults); + withDefaults.runtime = runtime(withDefaults); // ensure a valid list of integrations. // `integrations` returns an array of valid integration items. @@ -164,5 +179,9 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV // ensure a valid impressionsMode withDefaults.sync.impressionsMode = validImpressionsMode(log, withDefaults.sync.impressionsMode); + // ensure a valid user consent value + // @ts-ignore, modify readonly prop + withDefaults.userConsent = consent(withDefaults); + return withDefaults; } diff --git a/src/utils/settingsValidation/runtime.ts b/src/utils/settingsValidation/runtime.ts new file mode 100644 index 00000000..2f53ac54 --- /dev/null +++ b/src/utils/settingsValidation/runtime.ts @@ -0,0 +1,9 @@ +import { ISettings } from '../../types'; + +// For client-side SDKs, machine IP and Hostname are not captured and sent to Split backend. +export function validateRuntime(): ISettings['runtime'] { + return { + ip: false, + hostname: false + }; +} diff --git a/src/utils/settingsValidation/types.ts b/src/utils/settingsValidation/types.ts index db801b12..2322d042 100644 --- a/src/utils/settingsValidation/types.ts +++ b/src/utils/settingsValidation/types.ts @@ -6,18 +6,24 @@ import { ISettings } from '../../types'; */ export interface ISettingsValidationParams { /** - * Object of values to overwrite default settings. - * Version and startup properties are mandatory, because these values are not part of the base setting. + * Object of values to overwrite base settings. + * Version and startup properties are required, because they are not defined in the base settings. */ defaults: Partial & { version: string } & { startup: ISettings['startup'] }, - /** Function to overwrite runtime values (ip and hostname) which are false by default */ - runtime?: (settings: ISettings) => ISettings['runtime'], - /** Storage validator */ + /** If true, validates core.key */ + acceptKey?: boolean, + /** If true, validates core.trafficType */ + acceptTT?: boolean, + /** Define runtime values (`settings.runtime`) */ + runtime: (settings: ISettings) => ISettings['runtime'], + /** Storage validator (`settings.storage`) */ storage?: (settings: ISettings) => ISettings['storage'], - /** Integrations validator */ + /** Integrations validator (`settings.integrations`) */ integrations?: (settings: ISettings) => ISettings['integrations'], /** Logger validator (`settings.debug`) */ logger: (settings: ISettings) => ISettings['log'], /** Localhost mode validator (`settings.sync.localhostMode`) */ localhost?: (settings: ISettings) => ISettings['sync']['localhostMode'], + /** User consent validator (`settings.userConsent`) */ + consent: (settings: ISettings) => ISettings['userConsent'], } diff --git a/src/utils/time/__tests__/index.spec.ts b/src/utils/time/__tests__/index.spec.ts new file mode 100644 index 00000000..9bd0c8fe --- /dev/null +++ b/src/utils/time/__tests__/index.spec.ts @@ -0,0 +1,9 @@ +import { truncateTimeFrame } from '..'; + +test('Test truncateTimeFrame', () => { + expect(truncateTimeFrame(new Date(2020, 9, 2, 10, 53, 12).getTime())).toBe(new Date(2020, 9, 2, 10, 0, 0).getTime()); + expect(truncateTimeFrame(new Date(2020, 9, 2, 10, 0, 0).getTime())).toBe(new Date(2020, 9, 2, 10, 0, 0).getTime()); + expect(truncateTimeFrame(new Date(2020, 9, 2, 10, 53, 0).getTime())).toBe(new Date(2020, 9, 2, 10, 0, 0).getTime()); + expect(truncateTimeFrame(new Date(2020, 9, 2, 10, 0, 12).getTime())).toBe(new Date(2020, 9, 2, 10, 0, 0).getTime()); + expect(truncateTimeFrame(new Date(1970, 1, 0, 0, 0, 0).getTime())).toBe(new Date(1970, 1, 0, 0, 0, 0).getTime()); +}); diff --git a/src/utils/timeTracker/now/__tests__/now.spec.ts b/src/utils/timeTracker/now/__tests__/now.spec.ts new file mode 100644 index 00000000..ded2c815 --- /dev/null +++ b/src/utils/timeTracker/now/__tests__/now.spec.ts @@ -0,0 +1,30 @@ +/** +Copyright 2022 Split Software + +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 { now as nowBrowser } from '../browser'; +import { now as nowNode } from '../node'; + +[nowBrowser, nowNode].forEach(now => { + test('NOW / should generate a value each time you call it', () => { + let n1 = now(); + let n2 = now(); + let n3 = now(); + + expect(Number.isFinite(n1)).toBe(true); // is a finite value? + expect(Number.isFinite(n2)).toBe(true); // is a finite value? + expect(Number.isFinite(n3)).toBe(true); // is a finite value? + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 1d719785..20433c1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ // "removeComments": true, /* Do not emit comments to output. */ // "noEmit": true, /* Do not emit outputs. */ - // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + "importHelpers": true, /* Import emit helpers from 'tslib', to avoid duplicated helpers in builds. */ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */