Skip to content

Commit 716565e

Browse files
[FSSDK-11119] test addition + adjustment
1 parent ac1c4d7 commit 716565e

File tree

3 files changed

+457
-109
lines changed

3 files changed

+457
-109
lines changed

lib/core/bucketer/index.spec.ts

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
/**
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
17+
import { sprintf } from '../../utils/fns';
18+
import projectConfig, { ProjectConfig } from '../../project_config/project_config';
19+
import { getTestProjectConfig } from '../../tests/test_data';
20+
import { INVALID_BUCKETING_ID, INVALID_GROUP_ID } from 'error_message';
21+
import * as bucketer from './';
22+
import {
23+
USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
24+
USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
25+
USER_NOT_IN_ANY_EXPERIMENT,
26+
USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
27+
} from '.';
28+
import { BucketerParams } from '../../shared_types';
29+
import { OptimizelyError } from '../../error/optimizly_error';
30+
import { getMockLogger } from '../../tests/mock/mock_logger';
31+
import { LoggerFacade } from '../../logging/logger';
32+
33+
const testData = getTestProjectConfig();
34+
35+
function cloneDeep<T>(value: T): T {
36+
if (value === null || typeof value !== 'object') {
37+
return value;
38+
}
39+
40+
if (Array.isArray(value)) {
41+
return (value.map(cloneDeep) as unknown) as T;
42+
}
43+
44+
const copy: Record<string, unknown> = {};
45+
46+
for (const key in value) {
47+
if (Object.prototype.hasOwnProperty.call(value, key)) {
48+
copy[key] = cloneDeep((value as Record<string, unknown>)[key]);
49+
}
50+
}
51+
52+
return copy as T;
53+
}
54+
55+
const setLogSpy = (logger: LoggerFacade) => {
56+
vi.spyOn(logger, 'info');
57+
vi.spyOn(logger, 'debug');
58+
vi.spyOn(logger, 'warn');
59+
vi.spyOn(logger, 'error');
60+
};
61+
62+
describe('excluding groups', () => {
63+
let configObj;
64+
const mockLogger = getMockLogger();
65+
let bucketerParams: BucketerParams;
66+
67+
beforeEach(() => {
68+
setLogSpy(mockLogger);
69+
configObj = projectConfig.createProjectConfig(cloneDeep(testData));
70+
71+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
72+
// @ts-ignore
73+
bucketerParams = {
74+
experimentId: configObj.experiments[0].id,
75+
experimentKey: configObj.experiments[0].key,
76+
trafficAllocationConfig: configObj.experiments[0].trafficAllocation,
77+
variationIdMap: configObj.variationIdMap,
78+
experimentIdMap: configObj.experimentIdMap,
79+
groupIdMap: configObj.groupIdMap,
80+
logger: mockLogger,
81+
};
82+
});
83+
84+
afterEach(() => {
85+
vi.restoreAllMocks();
86+
});
87+
88+
it('should return decision response with correct variation ID when provided bucket value', async () => {
89+
const bucketerParamsTest1 = cloneDeep(bucketerParams);
90+
bucketerParamsTest1.userId = 'ppid1';
91+
const decisionResponse = bucketer.bucket(bucketerParamsTest1);
92+
93+
expect(decisionResponse.result).toBe('111128');
94+
expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid1');
95+
96+
const bucketerParamsTest2 = cloneDeep(bucketerParams);
97+
bucketerParamsTest2.userId = 'ppid2';
98+
bucketerParamsTest2.bucketingId = 'test_3166_1739796928766';
99+
const decisionResponse2 = bucketer.bucket(bucketerParamsTest2);
100+
101+
expect(decisionResponse2.result).toBe(null);
102+
expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'ppid2');
103+
});
104+
});
105+
106+
describe('including groups: random', () => {
107+
let configObj: ProjectConfig;
108+
const mockLogger = getMockLogger();
109+
let bucketerParams: BucketerParams;
110+
111+
beforeEach(() => {
112+
setLogSpy(mockLogger);
113+
configObj = projectConfig.createProjectConfig(cloneDeep(testData));
114+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
115+
// @ts-ignore
116+
bucketerParams = {
117+
experimentId: configObj.experiments[4].id,
118+
experimentKey: configObj.experiments[4].key,
119+
trafficAllocationConfig: configObj.experiments[4].trafficAllocation,
120+
variationIdMap: configObj.variationIdMap,
121+
experimentIdMap: configObj.experimentIdMap,
122+
groupIdMap: configObj.groupIdMap,
123+
logger: mockLogger,
124+
userId: 'testUser',
125+
bucketingId: 'test_303_1739432593254',
126+
};
127+
});
128+
129+
afterEach(() => {
130+
vi.restoreAllMocks();
131+
});
132+
133+
it('should return decision response with the proper variation for a user in a grouped experiment', () => {
134+
const decisionResponse = bucketer.bucket(bucketerParams);
135+
136+
expect(decisionResponse.result).toBe('551');
137+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
138+
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
139+
expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
140+
expect(mockLogger.info).toHaveBeenCalledWith(
141+
USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
142+
'testUser',
143+
'groupExperiment1',
144+
'666'
145+
);
146+
});
147+
148+
it('should return decision response with variation null when a user is bucketed into a different grouped experiment than the one speicfied', () => {
149+
bucketerParams.bucketingId = '123456789';
150+
const decisionResponse = bucketer.bucket(bucketerParams);
151+
152+
expect(decisionResponse.result).toBeNull();
153+
expect(mockLogger.debug).toHaveBeenCalledTimes(1);
154+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
155+
expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
156+
expect(mockLogger.info).toHaveBeenCalledWith(
157+
USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
158+
'testUser',
159+
'groupExperiment1',
160+
'666'
161+
);
162+
});
163+
164+
it('should return decision response with variation null when a user is not bucketed into any experiments in the random group', () => {
165+
bucketerParams.bucketingId = 'test_1228_1739468735344';
166+
const decisionResponse = bucketer.bucket(bucketerParams);
167+
168+
expect(decisionResponse.result).toBe(null);
169+
expect(mockLogger.debug).toHaveBeenCalledTimes(1);
170+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
171+
expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
172+
expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666');
173+
});
174+
175+
it('should return decision response with variation null when a user is bucketed into traffic space of deleted experiment within a random group', () => {
176+
bucketerParams.bucketingId = 'test_1228_1739468735344';
177+
const decisionResponse = bucketer.bucket(bucketerParams);
178+
179+
expect(decisionResponse.result).toBe(null);
180+
expect(mockLogger.debug).toHaveBeenCalledTimes(1);
181+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
182+
expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
183+
expect(mockLogger.info).toHaveBeenCalledWith(USER_NOT_IN_ANY_EXPERIMENT, 'testUser', '666');
184+
});
185+
186+
it('should throw an error if group ID is not in the datafile', () => {
187+
const bucketerParamsWithInvalidGroupId = cloneDeep(bucketerParams);
188+
bucketerParamsWithInvalidGroupId.experimentIdMap[configObj.experiments[4].id].groupId = '6969';
189+
190+
expect(() => bucketer.bucket(bucketerParamsWithInvalidGroupId)).toThrowError(
191+
new OptimizelyError(INVALID_GROUP_ID, '6969')
192+
);
193+
});
194+
});
195+
196+
describe('including groups: overlapping', () => {
197+
let configObj: ProjectConfig;
198+
const mockLogger = getMockLogger();
199+
let bucketerParams: BucketerParams;
200+
201+
beforeEach(() => {
202+
setLogSpy(mockLogger);
203+
configObj = projectConfig.createProjectConfig(cloneDeep(testData));
204+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
205+
// @ts-ignore
206+
bucketerParams = {
207+
experimentId: configObj.experiments[6].id,
208+
experimentKey: configObj.experiments[6].key,
209+
trafficAllocationConfig: configObj.experiments[6].trafficAllocation,
210+
variationIdMap: configObj.variationIdMap,
211+
experimentIdMap: configObj.experimentIdMap,
212+
groupIdMap: configObj.groupIdMap,
213+
logger: mockLogger,
214+
userId: 'testUser',
215+
};
216+
});
217+
218+
afterEach(() => {
219+
vi.restoreAllMocks();
220+
});
221+
222+
it('should return decision response with variation when a user falls into an experiment within an overlapping group', () => {
223+
bucketerParams.bucketingId = 'test_4283_1739793857480';
224+
const decisionResponse = bucketer.bucket(bucketerParams);
225+
226+
expect(decisionResponse.result).toBe('553');
227+
expect(mockLogger.debug).toHaveBeenCalledTimes(1);
228+
expect(mockLogger.debug).toHaveBeenCalledWith(USER_ASSIGNED_TO_EXPERIMENT_BUCKET, expect.any(Number), 'testUser');
229+
});
230+
231+
it('should return decision response with variation null when a user does not fall into an experiment within an overlapping group', () => {
232+
bucketerParams.bucketingId = 'test_9318_1739793997430';
233+
const decisionResponse = bucketer.bucket(bucketerParams);
234+
235+
expect(decisionResponse.result).toBe(null);
236+
});
237+
});
238+
239+
describe('bucket value falls into empty traffic allocation ranges', () => {
240+
let configObj: ProjectConfig;
241+
const mockLogger = getMockLogger();
242+
let bucketerParams: BucketerParams;
243+
244+
beforeEach(() => {
245+
setLogSpy(mockLogger);
246+
configObj = projectConfig.createProjectConfig(cloneDeep(testData));
247+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
248+
// @ts-ignore
249+
bucketerParams = {
250+
experimentId: configObj.experiments[0].id,
251+
experimentKey: configObj.experiments[0].key,
252+
trafficAllocationConfig: [
253+
{
254+
entityId: '',
255+
endOfRange: 5000,
256+
},
257+
{
258+
entityId: '',
259+
endOfRange: 10000,
260+
},
261+
],
262+
variationIdMap: configObj.variationIdMap,
263+
experimentIdMap: configObj.experimentIdMap,
264+
groupIdMap: configObj.groupIdMap,
265+
logger: mockLogger,
266+
};
267+
});
268+
269+
afterEach(() => {
270+
vi.restoreAllMocks();
271+
});
272+
273+
it('should return decision response with variation null', () => {
274+
const bucketerParamsTest1 = cloneDeep(bucketerParams);
275+
bucketerParamsTest1.userId = 'ppid1';
276+
const decisionResponse = bucketer.bucket(bucketerParamsTest1);
277+
278+
expect(decisionResponse.result).toBe(null);
279+
});
280+
281+
it('should not log an invalid variation ID warning', () => {
282+
bucketer.bucket(bucketerParams);
283+
284+
expect(mockLogger.warn).not.toHaveBeenCalled();
285+
});
286+
});
287+
288+
describe('traffic allocation has invalid variation ids', () => {
289+
let configObj: ProjectConfig;
290+
const mockLogger = getMockLogger();
291+
let bucketerParams: BucketerParams;
292+
293+
beforeEach(() => {
294+
setLogSpy(mockLogger);
295+
configObj = projectConfig.createProjectConfig(cloneDeep(testData));
296+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
297+
//@ts-ignore
298+
bucketerParams = {
299+
experimentId: configObj.experiments[0].id,
300+
experimentKey: configObj.experiments[0].key,
301+
trafficAllocationConfig: [
302+
{
303+
entityId: '-1',
304+
endOfRange: 5000,
305+
},
306+
{
307+
entityId: '-2',
308+
endOfRange: 10000,
309+
},
310+
],
311+
variationIdMap: configObj.variationIdMap,
312+
experimentIdMap: configObj.experimentIdMap,
313+
groupIdMap: configObj.groupIdMap,
314+
logger: mockLogger,
315+
};
316+
});
317+
318+
afterEach(() => {
319+
vi.restoreAllMocks();
320+
});
321+
322+
it('should return decision response with variation null', () => {
323+
const bucketerParamsTest1 = cloneDeep(bucketerParams);
324+
bucketerParamsTest1.userId = 'ppid1';
325+
const decisionResponse = bucketer.bucket(bucketerParamsTest1);
326+
327+
expect(decisionResponse.result).toBe(null);
328+
});
329+
});
330+
331+
describe('_generateBucketValue', () => {
332+
it('should return a bucket value for different inputs', () => {
333+
const experimentId = 1886780721;
334+
const bucketingKey1 = sprintf('%s%s', 'ppid1', experimentId);
335+
const bucketingKey2 = sprintf('%s%s', 'ppid2', experimentId);
336+
const bucketingKey3 = sprintf('%s%s', 'ppid2', 1886780722);
337+
const bucketingKey4 = sprintf('%s%s', 'ppid3', experimentId);
338+
339+
expect(bucketer._generateBucketValue(bucketingKey1)).toBe(5254);
340+
expect(bucketer._generateBucketValue(bucketingKey2)).toBe(4299);
341+
expect(bucketer._generateBucketValue(bucketingKey3)).toBe(2434);
342+
expect(bucketer._generateBucketValue(bucketingKey4)).toBe(5439);
343+
});
344+
345+
it('should return an error if it cannot generate the hash value', () => {
346+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
347+
// @ts-ignore
348+
expect(() => bucketer._generateBucketValue(null)).toThrowError(new OptimizelyError(INVALID_BUCKETING_ID));
349+
});
350+
});
351+
352+
describe('testBucketWithBucketingId', () => {
353+
let bucketerParams: BucketerParams;
354+
355+
beforeEach(() => {
356+
const configObj = projectConfig.createProjectConfig(cloneDeep(testData));
357+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
358+
// @ts-ignore
359+
bucketerParams = {
360+
trafficAllocationConfig: configObj.experiments[0].trafficAllocation,
361+
variationIdMap: configObj.variationIdMap,
362+
experimentIdMap: configObj.experimentIdMap,
363+
groupIdMap: configObj.groupIdMap,
364+
};
365+
});
366+
367+
it('check that a non null bucketingId buckets a variation different than the one expected with userId', () => {
368+
const bucketerParams1 = cloneDeep(bucketerParams);
369+
bucketerParams1['userId'] = 'testBucketingIdControl';
370+
bucketerParams1['bucketingId'] = '123456789';
371+
bucketerParams1['experimentKey'] = 'testExperiment';
372+
bucketerParams1['experimentId'] = '111127';
373+
374+
expect(bucketer.bucket(bucketerParams1).result).toBe('111129');
375+
});
376+
377+
it('check that a null bucketing ID defaults to bucketing with the userId', () => {
378+
const bucketerParams2 = cloneDeep(bucketerParams);
379+
bucketerParams2['userId'] = 'testBucketingIdControl';
380+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
381+
// @ts-ignore
382+
bucketerParams2['bucketingId'] = null;
383+
bucketerParams2['experimentKey'] = 'testExperiment';
384+
bucketerParams2['experimentId'] = '111127';
385+
386+
expect(bucketer.bucket(bucketerParams2).result).toBe('111128');
387+
});
388+
389+
it('check that bucketing works with an experiment in group', () => {
390+
const bucketerParams4 = cloneDeep(bucketerParams);
391+
bucketerParams4['userId'] = 'testBucketingIdControl';
392+
bucketerParams4['bucketingId'] = '123456789';
393+
bucketerParams4['experimentKey'] = 'groupExperiment2';
394+
bucketerParams4['experimentId'] = '443';
395+
396+
expect(bucketer.bucket(bucketerParams4).result).toBe('111128');
397+
});
398+
});

0 commit comments

Comments
 (0)