diff --git a/lib/core/bucketer/bucket_value_generator.spec.ts b/lib/core/bucketer/bucket_value_generator.spec.ts new file mode 100644 index 000000000..a7662e1f0 --- /dev/null +++ b/lib/core/bucketer/bucket_value_generator.spec.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { expect, describe, it } from 'vitest'; +import { sprintf } from '../../utils/fns'; +import { generateBucketValue } from './bucket_value_generator'; +import { OptimizelyError } from '../../error/optimizly_error'; +import { INVALID_BUCKETING_ID } from 'error_message'; + +describe('generateBucketValue', () => { + it('should return a bucket value for different inputs', () => { + const experimentId = 1886780721; + const bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId); + const bucketingKey2 = sprintf('%s%s', 'ppid2', experimentId); + const bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); + const bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); + + expect(generateBucketValue(bucketingKey1)).toBe(5254); + expect(generateBucketValue(bucketingKey2)).toBe(4299); + expect(generateBucketValue(bucketingKey3)).toBe(2434); + expect(generateBucketValue(bucketingKey4)).toBe(5439); + }); + + it('should return an error if it cannot generate the hash value', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => generateBucketValue(null)).toThrowError( + new OptimizelyError(INVALID_BUCKETING_ID) + ); + }); +}); diff --git a/lib/core/bucketer/bucket_value_generator.ts b/lib/core/bucketer/bucket_value_generator.ts new file mode 100644 index 000000000..c5f85303b --- /dev/null +++ b/lib/core/bucketer/bucket_value_generator.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import murmurhash from 'murmurhash'; +import { INVALID_BUCKETING_ID } from 'error_message'; +import { OptimizelyError } from '../../error/optimizly_error'; + +const HASH_SEED = 1; +const MAX_HASH_VALUE = Math.pow(2, 32); +const MAX_TRAFFIC_VALUE = 10000; + +/** + * Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE) + * @param {string} bucketingKey String value for bucketing + * @return {number} The generated bucket value + * @throws If bucketing value is not a valid string + */ +export const generateBucketValue = function(bucketingKey: string): number { + try { + // NOTE: the mmh library already does cast the hash value as an unsigned 32bit int + // https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115 + const hashValue = murmurhash.v3(bucketingKey, HASH_SEED); + const ratio = hashValue / MAX_HASH_VALUE; + return Math.floor(ratio * MAX_TRAFFIC_VALUE); + } catch (ex) { + throw new OptimizelyError(INVALID_BUCKETING_ID, bucketingKey, ex.message); + } +}; diff --git a/lib/core/bucketer/index.spec.ts b/lib/core/bucketer/index.spec.ts new file mode 100644 index 000000000..36f23b2eb --- /dev/null +++ b/lib/core/bucketer/index.spec.ts @@ -0,0 +1,391 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { sprintf } from '../../utils/fns'; +import projectConfig, { ProjectConfig } from '../../project_config/project_config'; +import { getTestProjectConfig } from '../../tests/test_data'; +import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message'; +import * as bucketer from './'; +import * as bucketValueGenerator from './bucket_value_generator'; + +import { + USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + USER_NOT_IN_ANY_EXPERIMENT, + USER_ASSIGNED_TO_EXPERIMENT_BUCKET, +} from '.'; +import { BucketerParams } from '../../shared_types'; +import { OptimizelyError } from '../../error/optimizly_error'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import { LoggerFacade } from '../../logging/logger'; + +const testData = getTestProjectConfig(); + +function cloneDeep(value: T): T { + if (value === null || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return (value.map(cloneDeep) as unknown) as T; + } + + const copy: Record = {}; + + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + copy[key] = cloneDeep((value as Record)[key]); + } + } + + return copy as T; +} + +const setLogSpy = (logger: LoggerFacade) => { + vi.spyOn(logger, 'info'); + vi.spyOn(logger, 'debug'); + vi.spyOn(logger, 'warn'); + vi.spyOn(logger, 'error'); +}; + +describe('excluding groups', () => { + let configObj; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[0].id, + experimentKey: configObj.experiments[0].key, + trafficAllocationConfig: configObj.experiments[0].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + }; + + vi.spyOn(bucketValueGenerator, 'generateBucketValue') + .mockReturnValueOnce(50) + .mockReturnValueOnce(50000); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with correct variation ID when provided bucket value', async () => { + const bucketerParamsTest1 = cloneDeep(bucketerParams); + bucketerParamsTest1.userId = 'ppid1'; + const decisionResponse = bucketer.bucket(bucketerParamsTest1); + + expect(decisionResponse.result).toBe('111128'); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid1'); + + const bucketerParamsTest2 = cloneDeep(bucketerParams); + bucketerParamsTest2.userId = 'ppid2'; + const decisionResponse2 = bucketer.bucket(bucketerParamsTest2); + + expect(decisionResponse2.result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid2'); + }); +}); + +describe('including groups: random', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[4].id, + experimentKey: configObj.experiments[4].key, + trafficAllocationConfig: configObj.experiments[4].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + userId: 'testUser', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with the proper variation for a user in a grouped experiment', () => { + vi.spyOn(bucketValueGenerator, 'generateBucketValue') + .mockReturnValueOnce(50) + .mockReturnValueOnce(50); + + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBe('551'); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledTimes(2); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith( + USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + 'testUser', + 'groupExperiment1', + '666' + ); + }); + + it('should return decision response with variation null when a user is bucketed into a different grouped experiment than the one speicfied', () => { + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValue(5000); + + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith( + USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP, + 'testUser', + 'groupExperiment1', + '666' + ); + }); + + it('should return decision response with variation null when a user is not bucketed into any experiments in the random group', () => { + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValue(50000); + + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666'); + }); + + it('should return decision response with variation null when a user is bucketed into traffic space of deleted experiment within a random group', () => { + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(9000); + + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666'); + }); + + it('should throw an error if group ID is not in the datafile', () => { + const bucketerParamsWithInvalidGroupId = cloneDeep(bucketerParams); + bucketerParamsWithInvalidGroupId.experimentIdMap[configObj.experiments[4].id].groupId = '6969'; + + expect(() => bucketer.bucket(bucketerParamsWithInvalidGroupId)).toThrowError( + new OptimizelyError(INVALID_GROUP_ID, '6969') + ); + }); +}); + +describe('including groups: overlapping', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[6].id, + experimentKey: configObj.experiments[6].key, + trafficAllocationConfig: configObj.experiments[6].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + userId: 'testUser', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with variation when a user falls into an experiment within an overlapping group', () => { + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(0); + + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBe('553'); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser'); + }); + + it('should return decision response with variation null when a user does not fall into an experiment within an overlapping group', () => { + vi.spyOn(bucketValueGenerator, 'generateBucketValue').mockReturnValueOnce(3000); + const decisionResponse = bucketer.bucket(bucketerParams); + + expect(decisionResponse.result).toBeNull(); + }); +}); + +describe('bucket value falls into empty traffic allocation ranges', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + experimentId: configObj.experiments[0].id, + experimentKey: configObj.experiments[0].key, + trafficAllocationConfig: [ + { + entityId: '', + endOfRange: 5000, + }, + { + entityId: '', + endOfRange: 10000, + }, + ], + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with variation null', () => { + const bucketerParamsTest1 = cloneDeep(bucketerParams); + bucketerParamsTest1.userId = 'ppid1'; + const decisionResponse = bucketer.bucket(bucketerParamsTest1); + + expect(decisionResponse.result).toBeNull(); + }); + + it('should not log an invalid variation ID warning', () => { + bucketer.bucket(bucketerParams); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('traffic allocation has invalid variation ids', () => { + let configObj: ProjectConfig; + const mockLogger = getMockLogger(); + let bucketerParams: BucketerParams; + + beforeEach(() => { + setLogSpy(mockLogger); + configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + bucketerParams = { + experimentId: configObj.experiments[0].id, + experimentKey: configObj.experiments[0].key, + trafficAllocationConfig: [ + { + entityId: '-1', + endOfRange: 5000, + }, + { + entityId: '-2', + endOfRange: 10000, + }, + ], + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + logger: mockLogger, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return decision response with variation null', () => { + const bucketerParamsTest1 = cloneDeep(bucketerParams); + bucketerParamsTest1.userId = 'ppid1'; + const decisionResponse = bucketer.bucket(bucketerParamsTest1); + + expect(decisionResponse.result).toBeNull(); + }); +}); + + + +describe('testBucketWithBucketingId', () => { + let bucketerParams: BucketerParams; + + beforeEach(() => { + const configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams = { + trafficAllocationConfig: configObj.experiments[0].trafficAllocation, + variationIdMap: configObj.variationIdMap, + experimentIdMap: configObj.experimentIdMap, + groupIdMap: configObj.groupIdMap, + }; + }); + + it('check that a non null bucketingId buckets a variation different than the one expected with userId', () => { + const bucketerParams1 = cloneDeep(bucketerParams); + bucketerParams1['userId'] = 'testBucketingIdControl'; + bucketerParams1['bucketingId'] = '123456789'; + bucketerParams1['experimentKey'] = 'testExperiment'; + bucketerParams1['experimentId'] = '111127'; + + expect(bucketer.bucket(bucketerParams1).result).toBe('111129'); + }); + + it('check that a null bucketing ID defaults to bucketing with the userId', () => { + const bucketerParams2 = cloneDeep(bucketerParams); + bucketerParams2['userId'] = 'testBucketingIdControl'; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + bucketerParams2['bucketingId'] = null; + bucketerParams2['experimentKey'] = 'testExperiment'; + bucketerParams2['experimentId'] = '111127'; + + expect(bucketer.bucket(bucketerParams2).result).toBe('111128'); + }); + + it('check that bucketing works with an experiment in group', () => { + const bucketerParams4 = cloneDeep(bucketerParams); + bucketerParams4['userId'] = 'testBucketingIdControl'; + bucketerParams4['bucketingId'] = '123456789'; + bucketerParams4['experimentKey'] = 'groupExperiment2'; + bucketerParams4['experimentId'] = '443'; + + expect(bucketer.bucket(bucketerParams4).result).toBe('111128'); + }); +}); diff --git a/lib/core/bucketer/index.tests.js b/lib/core/bucketer/index.tests.js index 023431af7..0bdf62f4a 100644 --- a/lib/core/bucketer/index.tests.js +++ b/lib/core/bucketer/index.tests.js @@ -17,7 +17,7 @@ import sinon from 'sinon'; import { assert, expect } from 'chai'; import { cloneDeep, create } from 'lodash'; import { sprintf } from '../../utils/fns'; - +import * as bucketValueGenerator from './bucket_value_generator' import * as bucketer from './'; import { LOG_LEVEL } from '../../utils/enums'; import projectConfig from '../../project_config/project_config'; @@ -76,7 +76,7 @@ describe('lib/core/bucketer', function () { logger: createdLogger, }; sinon - .stub(bucketer, '_generateBucketValue') + .stub(bucketValueGenerator, 'generateBucketValue') .onFirstCall() .returns(50) .onSecondCall() @@ -84,7 +84,7 @@ describe('lib/core/bucketer', function () { }); afterEach(function () { - bucketer._generateBucketValue.restore(); + bucketValueGenerator.generateBucketValue.restore(); }); it('should return decision response with correct variation ID when provided bucket value', function () { @@ -116,11 +116,11 @@ describe('lib/core/bucketer', function () { groupIdMap: configObj.groupIdMap, logger: createdLogger, }; - bucketerStub = sinon.stub(bucketer, '_generateBucketValue'); + bucketerStub = sinon.stub(bucketValueGenerator, 'generateBucketValue'); }); afterEach(function () { - bucketer._generateBucketValue.restore(); + bucketValueGenerator.generateBucketValue.restore(); }); describe('random groups', function () { @@ -328,7 +328,7 @@ describe('lib/core/bucketer', function () { }); }); - describe('_generateBucketValue', function () { + describe('generateBucketValue', function () { it('should return a bucket value for different inputs', function () { var experimentId = 1886780721; var bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId); @@ -336,15 +336,15 @@ describe('lib/core/bucketer', function () { var bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722); var bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId); - expect(bucketer._generateBucketValue(bucketingKey1)).to.equal(5254); - expect(bucketer._generateBucketValue(bucketingKey2)).to.equal(4299); - expect(bucketer._generateBucketValue(bucketingKey3)).to.equal(2434); - expect(bucketer._generateBucketValue(bucketingKey4)).to.equal(5439); + expect(bucketValueGenerator.generateBucketValue(bucketingKey1)).to.equal(5254); + expect(bucketValueGenerator.generateBucketValue(bucketingKey2)).to.equal(4299); + expect(bucketValueGenerator.generateBucketValue(bucketingKey3)).to.equal(2434); + expect(bucketValueGenerator.generateBucketValue(bucketingKey4)).to.equal(5439); }); it('should return an error if it cannot generate the hash value', function() { const response = assert.throws(function() { - bucketer._generateBucketValue(null); + bucketValueGenerator.generateBucketValue(null); } ); expect(response.baseMessage).to.equal(INVALID_BUCKETING_ID); }); diff --git a/lib/core/bucketer/index.ts b/lib/core/bucketer/index.ts index d965e0217..b2455b95a 100644 --- a/lib/core/bucketer/index.ts +++ b/lib/core/bucketer/index.ts @@ -17,7 +17,6 @@ /** * Bucketer API for determining the variation id from the specified parameters */ -import murmurhash from 'murmurhash'; import { LoggerFacade } from '../../logging/logger'; import { DecisionResponse, @@ -25,19 +24,15 @@ import { TrafficAllocation, Group, } from '../../shared_types'; - -import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message'; +import { INVALID_GROUP_ID } from 'error_message'; import { OptimizelyError } from '../../error/optimizly_error'; +import { generateBucketValue } from './bucket_value_generator'; export const USER_NOT_IN_ANY_EXPERIMENT = 'User %s is not in any experiment of group %s.'; export const USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP = 'User %s is not in experiment %s of group %s.'; export const USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP = 'User %s is in experiment %s of group %s.'; export const USER_ASSIGNED_TO_EXPERIMENT_BUCKET = 'Assigned bucket %s to user with bucketing ID %s.'; export const INVALID_VARIATION_ID = 'Bucketed into an invalid variation ID. Returning null.'; - -const HASH_SEED = 1; -const MAX_HASH_VALUE = Math.pow(2, 32); -const MAX_TRAFFIC_VALUE = 10000; const RANDOM_POLICY = 'random'; /** @@ -128,7 +123,7 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse } } const bucketingId = `${bucketerParams.bucketingId}${bucketerParams.experimentId}`; - const bucketValue = _generateBucketValue(bucketingId); + const bucketValue = generateBucketValue(bucketingId); bucketerParams.logger?.debug( USER_ASSIGNED_TO_EXPERIMENT_BUCKET, @@ -176,7 +171,7 @@ export const bucketUserIntoExperiment = function( logger?: LoggerFacade ): string | null { const bucketingKey = `${bucketingId}${group.id}`; - const bucketValue = _generateBucketValue(bucketingKey); + const bucketValue = generateBucketValue(bucketingKey); logger?.debug( USER_ASSIGNED_TO_EXPERIMENT_BUCKET, bucketValue, @@ -208,26 +203,7 @@ export const _findBucket = function( return null; }; -/** - * Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE) - * @param {string} bucketingKey String value for bucketing - * @return {number} The generated bucket value - * @throws If bucketing value is not a valid string - */ -export const _generateBucketValue = function(bucketingKey: string): number { - try { - // NOTE: the mmh library already does cast the hash value as an unsigned 32bit int - // https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115 - const hashValue = murmurhash.v3(bucketingKey, HASH_SEED); - const ratio = hashValue / MAX_HASH_VALUE; - return Math.floor(ratio * MAX_TRAFFIC_VALUE); - } catch (ex: any) { - throw new OptimizelyError(INVALID_BUCKETING_ID, bucketingKey, ex.message); - } -}; - export default { bucket: bucket, bucketUserIntoExperiment: bucketUserIntoExperiment, - _generateBucketValue: _generateBucketValue, }; diff --git a/lib/core/custom_attribute_condition_evaluator/index.spec.ts b/lib/core/custom_attribute_condition_evaluator/index.spec.ts new file mode 100644 index 000000000..66f8cae0d --- /dev/null +++ b/lib/core/custom_attribute_condition_evaluator/index.spec.ts @@ -0,0 +1,1411 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as customAttributeEvaluator from './'; +import { MISSING_ATTRIBUTE_VALUE, UNEXPECTED_TYPE_NULL } from 'log_message'; +import { UNKNOWN_MATCH_TYPE, UNEXPECTED_TYPE, OUT_OF_BOUNDS, UNEXPECTED_CONDITION_VALUE } from 'error_message'; +import { Condition } from '../../shared_types'; +import { getMockLogger } from '../../tests/mock/mock_logger'; +import { LoggerFacade } from '../../logging/logger'; + +const browserConditionSafari = { + name: 'browser_type', + value: 'safari', + type: 'custom_attribute', +}; +const booleanCondition = { + name: 'is_firefox', + value: true, + type: 'custom_attribute', +}; +const integerCondition = { + name: 'num_users', + value: 10, + type: 'custom_attribute', +}; +const doubleCondition = { + name: 'pi_value', + value: 3.14, + type: 'custom_attribute', +}; + +const getMockUserContext: any = (attributes: any) => ({ + getAttributes: () => ({ ...(attributes || {}) }), +}); + +const setLogSpy = (logger: LoggerFacade) => { + vi.spyOn(logger, 'error'); + vi.spyOn(logger, 'debug'); + vi.spyOn(logger, 'info'); + vi.spyOn(logger, 'warn'); +}; + +describe('custom_attribute_condition_evaluator', () => { + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true when the attributes pass the audience conditions and no match type is provided', () => { + const userAttributes = { + browser_type: 'safari', + }; + + expect( + customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)) + ).toBe(true); + }); + + it('should return false when the attributes do not pass the audience conditions and no match type is provided', () => { + const userAttributes = { + browser_type: 'firefox', + }; + + expect( + customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)) + ).toBe(false); + }); + + it('should evaluate different typed attributes', () => { + const userAttributes = { + browser_type: 'safari', + is_firefox: true, + num_users: 10, + pi_value: 3.14, + }; + + expect( + customAttributeEvaluator.getEvaluator().evaluate(browserConditionSafari, getMockUserContext(userAttributes)) + ).toBe(true); + expect(customAttributeEvaluator.getEvaluator().evaluate(booleanCondition, getMockUserContext(userAttributes))).toBe( + true + ); + expect(customAttributeEvaluator.getEvaluator().evaluate(integerCondition, getMockUserContext(userAttributes))).toBe( + true + ); + expect(customAttributeEvaluator.getEvaluator().evaluate(doubleCondition, getMockUserContext(userAttributes))).toBe( + true + ); + }); + + it('should log and return null when condition has an invalid match property', () => { + const invalidMatchCondition = { match: 'weird', name: 'weird_condition', type: 'custom_attribute', value: 'hi' }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidMatchCondition, getMockUserContext({ weird_condition: 'bye' })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith(UNKNOWN_MATCH_TYPE, JSON.stringify(invalidMatchCondition)); + }); +}); + +describe('exists match type', () => { + const existsCondition = { + match: 'exists', + name: 'input_value', + type: 'custom_attribute', + value: '', + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if there is no user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(existsCondition, getMockUserContext({})); + + expect(result).toBe(false); + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('should return false if the user-provided value is undefined', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: undefined })); + + expect(result).toBe(false); + }); + + it('should return false if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: null })); + + expect(result).toBe(false); + }); + + it('should return true if the user-provided value is a string', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: 'hi' })); + + expect(result).toBe(true); + }); + + it('should return true if the user-provided value is a number', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: 10 })); + + expect(result).toBe(true); + }); + + it('should return true if the user-provided value is a boolean', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(existsCondition, getMockUserContext({ input_value: true })); + + expect(result).toBe(true); + }); +}); + +describe('exact match type - with a string condition value', () => { + const exactStringCondition = { + match: 'exact', + name: 'favorite_constellation', + type: 'custom_attribute', + value: 'Lacerta', + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: 'Lacerta' })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator() + .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: 'The Big Dipper' })); + + expect(result).toBe(false); + }); + + it('should log and return null if condition value is of an unexpected type', () => { + const invalidExactCondition = { + match: 'exact', + name: 'favorite_constellation', + type: 'custom_attribute', + value: null, + }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidExactCondition, getMockUserContext({ favorite_constellation: 'Lacerta' })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidExactCondition)); + }); + + it('should log and return null if the user-provided value is of a different type than the condition value', () => { + const unexpectedTypeUserAttributes: Record = { favorite_constellation: false }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext(unexpectedTypeUserAttributes)); + const userValue = unexpectedTypeUserAttributes[exactStringCondition.name]; + const userValueType = typeof userValue; + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactStringCondition), + userValueType, + exactStringCondition.name + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext({ favorite_constellation: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(exactStringCondition), + exactStringCondition.name + ); + }); + + it('should log and return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext({})); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + MISSING_ATTRIBUTE_VALUE, + JSON.stringify(exactStringCondition), + exactStringCondition.name + ); + }); + + it('should log and return null if the user-provided value is of an unexpected type', () => { + const unexpectedTypeUserAttributes: Record = { favorite_constellation: [] }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactStringCondition, getMockUserContext(unexpectedTypeUserAttributes)); + const userValue = unexpectedTypeUserAttributes[exactStringCondition.name]; + const userValueType = typeof userValue; + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactStringCondition), + userValueType, + exactStringCondition.name + ); + }); +}); + +describe('exact match type - with a number condition value', () => { + const exactNumberCondition = { + match: 'exact', + name: 'lasers_count', + type: 'custom_attribute', + value: 9000, + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 9000 })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: 8000 })); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is of a different type than the condition value', () => { + const unexpectedTypeUserAttributes1: Record = { lasers_count: 'yes' }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext(unexpectedTypeUserAttributes1)); + + expect(result).toBe(null); + + const unexpectedTypeUserAttributes2: Record = { lasers_count: '1000' }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext(unexpectedTypeUserAttributes2)); + + expect(result).toBe(null); + + const userValue1 = unexpectedTypeUserAttributes1[exactNumberCondition.name]; + const userValueType1 = typeof userValue1; + const userValue2 = unexpectedTypeUserAttributes2[exactNumberCondition.name]; + const userValueType2 = typeof userValue2; + + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactNumberCondition), + userValueType1, + exactNumberCondition.name + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(exactNumberCondition), + userValueType2, + exactNumberCondition.name + ); + }); + + it('should log and return null if the user-provided number value is out of bounds', () => { + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Infinity })); + + expect(result).toBe(null); + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({ lasers_count: -Math.pow(2, 53) - 2 })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + OUT_OF_BOUNDS, + JSON.stringify(exactNumberCondition), + exactNumberCondition.name + ); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactNumberCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); + + it('should log and return null if the condition value is not finite', () => { + const invalidValueCondition1 = { + match: 'exact', + name: 'lasers_count', + type: 'custom_attribute', + value: Infinity, + }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition1, getMockUserContext({ lasers_count: 9000 })); + + expect(result).toBe(null); + + const invalidValueCondition2 = { + match: 'exact', + name: 'lasers_count', + type: 'custom_attribute', + value: Math.pow(2, 53) + 2, + }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition2, getMockUserContext({ lasers_count: 9000 })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition1)); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition2)); + }); +}); + +describe('exact match type - with a boolean condition value', () => { + const exactBoolCondition = { + match: 'exact', + name: 'did_register_user', + type: 'custom_attribute', + value: false, + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: false })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not equal to the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: true })); + + expect(result).toBe(false); + }); + + it('should return null if the user-provided value is of a different type than the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({ did_register_user: 10 })); + + expect(result).toBe(null); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(exactBoolCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('substring match type', () => { + const mockLogger = getMockLogger(); + const substringCondition = { + match: 'substring', + name: 'headline_text', + type: 'custom_attribute', + value: 'buy now', + }; + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the condition value is a substring of the user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + substringCondition, + getMockUserContext({ + headline_text: 'Limited time, buy now!', + }) + ); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not a substring of the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + substringCondition, + getMockUserContext({ + headline_text: 'Breaking news!', + }) + ); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is not a string', () => { + const unexpectedTypeUserAttributes: Record = { headline_text: 10 }; + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(substringCondition, getMockUserContext(unexpectedTypeUserAttributes)); + const userValue = unexpectedTypeUserAttributes[substringCondition.name]; + const userValueType = typeof userValue; + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(substringCondition), + userValueType, + substringCondition.name + ); + }); + + it('should log and return null if the condition value is not a string', () => { + const nonStringCondition = { + match: 'substring', + name: 'headline_text', + type: 'custom_attribute', + value: 10, + }; + + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(nonStringCondition, getMockUserContext({ headline_text: 'hello' })); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(nonStringCondition)); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(substringCondition, getMockUserContext({ headline_text: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(substringCondition), + substringCondition.name + ); + }); + + it('should return null if there is no user-provided value', function() { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(substringCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('greater than match type', () => { + const gtCondition = { + match: 'gt', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is greater than the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: 58.4 })); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not greater than the condition value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: 20 })); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is not a number', () => { + const unexpectedTypeUserAttributes1 = { meters_travelled: 'a long way' }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext(unexpectedTypeUserAttributes1)); + + expect(result).toBeNull(); + + const unexpectedTypeUserAttributes2 = { meters_travelled: '1000' }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext(unexpectedTypeUserAttributes2)); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(gtCondition), + 'string', + gtCondition.name + ); + }); + + it('should log and return null if the user-provided number value is out of bounds', () => { + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: -Infinity })); + + expect(result).toBeNull(); + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 })); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(gtCondition), gtCondition.name); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(gtCondition, getMockUserContext({ meters_travelled: null })); + + expect(result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(UNEXPECTED_TYPE_NULL, JSON.stringify(gtCondition), gtCondition.name); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(gtCondition, getMockUserContext({})); + + expect(result).toBeNull(); + }); + + it('should return null if the condition value is not a finite number', () => { + const userAttributes = { meters_travelled: 58.4 }; + const invalidValueCondition: Condition = { + match: 'gt', + name: 'meters_travelled', + type: 'custom_attribute', + value: Infinity, + }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = null; + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = Math.pow(2, 53) + 2; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(3); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition)); + }); +}); + +describe('less than match type', () => { + const ltCondition = { + match: 'lt', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided value is less than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + ltCondition, + getMockUserContext({ + meters_travelled: 10, + }) + ); + + expect(result).toBe(true); + }); + + it('should return false if the user-provided value is not less than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + ltCondition, + getMockUserContext({ + meters_travelled: 64.64, + }) + ); + + expect(result).toBe(false); + }); + + it('should log and return null if the user-provided value is not a number', () => { + const unexpectedTypeUserAttributes1: Record = { meters_travelled: true }; + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext(unexpectedTypeUserAttributes1)); + + expect(result).toBeNull(); + + const unexpectedTypeUserAttributes2: Record = { meters_travelled: '48.2' }; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext(unexpectedTypeUserAttributes2)); + + expect(result).toBeNull(); + + const userValue1 = unexpectedTypeUserAttributes1[ltCondition.name]; + const userValueType1 = typeof userValue1; + const userValue2 = unexpectedTypeUserAttributes2[ltCondition.name]; + const userValueType2 = typeof userValue2; + + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(ltCondition), + userValueType1, + ltCondition.name + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(ltCondition), + userValueType2, + ltCondition.name + ); + }); + + it('should log and return null if the user-provided number value is out of bounds', () => { + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext({ meters_travelled: Infinity })); + + expect(result).toBeNull(); + + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext({ meters_travelled: Math.pow(2, 53) + 2 })); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name); + expect(mockLogger.warn).toHaveBeenCalledWith(OUT_OF_BOUNDS, JSON.stringify(ltCondition), ltCondition.name); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(ltCondition, getMockUserContext({ meters_travelled: null })); + + expect(result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith(UNEXPECTED_TYPE_NULL, JSON.stringify(ltCondition), ltCondition.name); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate(ltCondition, getMockUserContext({})); + + expect(result).toBeNull(); + }); + + it('should return null if the condition value is not a finite number', () => { + const userAttributes = { meters_travelled: 10 }; + const invalidValueCondition: Condition = { + match: 'lt', + name: 'meters_travelled', + type: 'custom_attribute', + value: Infinity, + }; + + let result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = null; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + + invalidValueCondition.value = Math.pow(2, 53) + 2; + result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(invalidValueCondition, getMockUserContext(userAttributes)); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledTimes(3); + expect(mockLogger.warn).toHaveBeenCalledWith(UNEXPECTED_CONDITION_VALUE, JSON.stringify(invalidValueCondition)); + }); +}); + +describe('less than or equal match type', () => { + const leCondition = { + match: 'le', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided value is greater than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + leCondition, + getMockUserContext({ + meters_travelled: 48.3, + }) + ); + + expect(result).toBe(false); + }); + + it('should return true if the user-provided value is less than or equal to the condition value', () => { + const versions = [48, 48.2]; + for (const userValue of versions) { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + leCondition, + getMockUserContext({ + meters_travelled: userValue, + }) + ); + + expect(result).toBe(true); + } + }); +}); + +describe('greater than and equal to match type', () => { + const geCondition = { + match: 'ge', + name: 'meters_travelled', + type: 'custom_attribute', + value: 48.2, + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided value is less than the condition value', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + geCondition, + getMockUserContext({ + meters_travelled: 48, + }) + ); + + expect(result).toBe(false); + }); + + it('should return true if the user-provided value is less than or equal to the condition value', () => { + const versions = [100, 48.2]; + versions.forEach(userValue => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + geCondition, + getMockUserContext({ + meters_travelled: userValue, + }) + ); + + expect(result).toBe(true); + }); + }); +}); + +describe('semver greater than match type', () => { + const semvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided version is greater than the condition version', () => { + const versions = [['1.8.1', '1.9']]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvergtCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should return false if the user-provided version is not greater than the condition version', function() { + const versions = [ + ['2.0.1', '2.0.1'], + ['2.0', '2.0.0'], + ['2.0', '2.0.1'], + ['2.0.1', '2.0.0'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvergtCondition = { + match: 'semver_gt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvergtCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should log and return null if the user-provided version is not a string', () => { + let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvergtCondition, + getMockUserContext({ + app_version: 22, + }) + ); + + expect(result).toBe(null); + + result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvergtCondition, + getMockUserContext({ + app_version: false, + }) + ); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvergtCondition), + 'number', + 'app_version' + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvergtCondition), + 'boolean', + 'app_version' + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvergtCondition, getMockUserContext({ app_version: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(semvergtCondition), + 'app_version' + ); + }); + + it('should return null if there is no user-provided value', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvergtCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('semver less than match type', () => { + const semverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided version is greater than the condition version', () => { + const versions = [ + ['2.0.0', '2.0.1'], + ['1.9', '2.0.0'], + ['2.0.0', '2.0.0'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemverltCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should return true if the user-provided version is less than the condition version', () => { + const versions = [ + ['2.0.1', '2.0.0'], + ['2.0.0', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemverltCondition = { + match: 'semver_lt', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemverltCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should log and return null if the user-provided version is not a string', () => { + let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semverltCondition, + getMockUserContext({ + app_version: 22, + }) + ); + + expect(result).toBe(null); + + result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semverltCondition, + getMockUserContext({ + app_version: false, + }) + ); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semverltCondition), + 'number', + 'app_version' + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semverltCondition), + 'boolean', + 'app_version' + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semverltCondition, getMockUserContext({ app_version: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(semverltCondition), + 'app_version' + ); + }); + + it('should return null if there is no user-provided value', function() { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semverltCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); +describe('semver equal to match type', () => { + const semvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: '2.0', + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided version is greater than the condition version', () => { + const versions = [ + ['2.0.0', '2.0.1'], + ['2.0.1', '2.0.0'], + ['1.9.1', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should return true if the user-provided version is equal to the condition version', () => { + const versions = [ + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_eq', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should log and return null if the user-provided version is not a string', () => { + let result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvereqCondition, + getMockUserContext({ + app_version: 22, + }) + ); + + expect(result).toBe(null); + + result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semvereqCondition, + getMockUserContext({ + app_version: false, + }) + ); + + expect(result).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvereqCondition), + 'number', + 'app_version' + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + UNEXPECTED_TYPE, + JSON.stringify(semvereqCondition), + 'boolean', + 'app_version' + ); + }); + + it('should log and return null if the user-provided value is null', () => { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvereqCondition, getMockUserContext({ app_version: null })); + + expect(result).toBe(null); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + UNEXPECTED_TYPE_NULL, + JSON.stringify(semvereqCondition), + 'app_version' + ); + }); + + it('should return null if there is no user-provided value', function() { + const result = customAttributeEvaluator + .getEvaluator(mockLogger) + .evaluate(semvereqCondition, getMockUserContext({})); + + expect(result).toBe(null); + }); +}); + +describe('semver less than or equal to match type', () => { + const semverleCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: '2.0.0', + }; + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return false if the user-provided version is greater than the condition version', () => { + const versions = [['2.0.0', '2.0.1']]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); + + it('should return true if the user-provided version is less than or equal to the condition version', () => { + const versions = [ + ['2.0.1', '2.0.0'], + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'], + ['1.9.1', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_le', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should return true if the user-provided version is equal to the condition version', () => { + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + semverleCondition, + getMockUserContext({ + app_version: '2.0', + }) + ); + + expect(result).toBe(true); + }); +}); + +describe('semver greater than or equal to match type', () => { + const mockLogger = getMockLogger(); + + beforeEach(() => { + setLogSpy(mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if the user-provided version is greater than or equal to the condition version', () => { + const versions = [ + ['2.0.0', '2.0.1'], + ['2.0.1', '2.0.1'], + ['1.9', '1.9.1'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(true); + }); + }); + + it('should return false if the user-provided version is less than the condition version', () => { + const versions = [ + ['2.0.1', '2.0.0'], + ['1.9.1', '1.9'], + ]; + versions.forEach(([targetVersion, userVersion]) => { + const customSemvereqCondition = { + match: 'semver_ge', + name: 'app_version', + type: 'custom_attribute', + value: targetVersion, + }; + const result = customAttributeEvaluator.getEvaluator(mockLogger).evaluate( + customSemvereqCondition, + getMockUserContext({ + app_version: userVersion, + }) + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/lib/core/decision/index.spec.ts b/lib/core/decision/index.spec.ts new file mode 100644 index 000000000..ea98fba39 --- /dev/null +++ b/lib/core/decision/index.spec.ts @@ -0,0 +1,128 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { describe, it, expect } from 'vitest'; +import { rolloutDecisionObj, featureTestDecisionObj } from '../../tests/test_data'; +import * as decision from './'; + +describe('getExperimentKey method', () => { + it('should return empty string when experiment is null', () => { + const experimentKey = decision.getExperimentKey(rolloutDecisionObj); + + expect(experimentKey).toEqual(''); + }); + + it('should return empty string when experiment is not defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const experimentKey = decision.getExperimentKey({}); + + expect(experimentKey).toEqual(''); + }); + + it('should return experiment key when experiment is defined', () => { + const experimentKey = decision.getExperimentKey(featureTestDecisionObj); + + expect(experimentKey).toEqual('testing_my_feature'); + }); +}); + +describe('getExperimentId method', () => { + it('should return null when experiment is null', () => { + const experimentId = decision.getExperimentId(rolloutDecisionObj); + + expect(experimentId).toEqual(null); + }); + + it('should return null when experiment is not defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const experimentId = decision.getExperimentId({}); + + expect(experimentId).toEqual(null); + }); + + it('should return experiment id when experiment is defined', () => { + const experimentId = decision.getExperimentId(featureTestDecisionObj); + + expect(experimentId).toEqual('594098'); + }); + + describe('getVariationKey method', ()=> { + it('should return empty string when variation is null', () => { + const variationKey = decision.getVariationKey(rolloutDecisionObj); + + expect(variationKey).toEqual(''); + }); + + it('should return empty string when variation is not defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const variationKey = decision.getVariationKey({}); + + expect(variationKey).toEqual(''); + }); + + it('should return variation key when variation is defined', () => { + const variationKey = decision.getVariationKey(featureTestDecisionObj); + + expect(variationKey).toEqual('variation'); + }); + }); + + describe('getVariationId method', () => { + it('should return null when variation is null', () => { + const variationId = decision.getVariationId(rolloutDecisionObj); + + expect(variationId).toEqual(null); + }); + + it('should return null when variation is not defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const variationId = decision.getVariationId({}); + + expect(variationId).toEqual(null); + }); + + it('should return variation id when variation is defined', () => { + const variationId = decision.getVariationId(featureTestDecisionObj); + + expect(variationId).toEqual('594096'); + }); + }); + + describe('getFeatureEnabledFromVariation method', () => { + it('should return false when variation is null', () => { + const featureEnabled = decision.getFeatureEnabledFromVariation(rolloutDecisionObj); + + expect(featureEnabled).toEqual(false); + }); + + it('should return false when variation is not defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const featureEnabled = decision.getFeatureEnabledFromVariation({}); + + expect(featureEnabled).toEqual(false); + }); + + it('should return featureEnabled boolean when variation is defined', () => { + const featureEnabled = decision.getFeatureEnabledFromVariation(featureTestDecisionObj); + + expect(featureEnabled).toEqual(true); + }); + }); +}); diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index 431b95efa..b723d118b 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -20,6 +20,7 @@ import { sprintf } from '../../utils/fns'; import { createDecisionService } from './'; import * as bucketer from '../bucketer'; +import * as bucketValueGenerator from '../bucketer/bucket_value_generator'; import { LOG_LEVEL, DECISION_SOURCES, @@ -2227,7 +2228,7 @@ describe('lib/core/decision_service', function() { var generateBucketValueStub; beforeEach(function() { feature = configObj.featureKeyMap.test_feature_in_exclusion_group; - generateBucketValueStub = sandbox.stub(bucketer, '_generateBucketValue'); + generateBucketValueStub = sandbox.stub(bucketValueGenerator, 'generateBucketValue'); }); it('returns a decision with a variation in mutex group bucket less than 2500', function() { @@ -2407,7 +2408,7 @@ describe('lib/core/decision_service', function() { var generateBucketValueStub; beforeEach(function() { feature = configObj.featureKeyMap.test_feature_in_multiple_experiments; - generateBucketValueStub = sandbox.stub(bucketer, '_generateBucketValue'); + generateBucketValueStub = sandbox.stub(bucketValueGenerator, 'generateBucketValue'); }); it('returns a decision with a variation in mutex group bucket less than 2500', function() { diff --git a/lib/project_config/optimizely_config.spec.ts b/lib/project_config/optimizely_config.spec.ts index 3e7288a8e..ab8d3ab5d 100644 --- a/lib/project_config/optimizely_config.spec.ts +++ b/lib/project_config/optimizely_config.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024, Optimizely + * Copyright 2025, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index 2ab002bca..a955b725a 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024, Optimizely + * Copyright 2025, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/lib/tests/test_data.ts b/lib/tests/test_data.ts index d792188fa..990096f7b 100644 --- a/lib/tests/test_data.ts +++ b/lib/tests/test_data.ts @@ -3573,12 +3573,14 @@ export var featureTestDecisionObj = { id: '594096', featureEnabled: true, variables: [], + variablesMap: {}, }, { key: 'control', id: '594097', featureEnabled: true, variables: [], + variablesMap: {} }, ], status: 'Running', @@ -3590,20 +3592,24 @@ export var featureTestDecisionObj = { id: '594096', featureEnabled: true, variables: [], + variablesMap: {} }, control: { key: 'control', id: '594097', featureEnabled: true, variables: [], + variablesMap: {} }, }, + audienceConditions: [] }, variation: { key: 'variation', id: '594096', featureEnabled: true, variables: [], + variablesMap: {} }, decisionSource: 'feature-test', };