Skip to content

Commit 0ae200b

Browse files
committed
feat: getExperimentContainer helper function for contentful app integration
1 parent 50b41bf commit 0ae200b

File tree

5 files changed

+155
-53
lines changed

5 files changed

+155
-53
lines changed

src/client/eppo-client-assignment-details.spec.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,13 @@ import {
55
MOCK_UFC_RESPONSE_FILE,
66
readMockUFCResponse,
77
} from '../../test/testHelpers';
8-
import ApiEndpoints from '../api-endpoints';
9-
import ConfigurationRequestor from '../configuration-requestor';
10-
import { IConfigurationStore } from '../configuration-store/configuration-store';
118
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
129
import { AllocationEvaluationCode } from '../flag-evaluation-details-builder';
13-
import FetchHttpClient from '../http-client';
1410
import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces';
1511
import { OperatorType } from '../rules';
1612

1713
import EppoClient, { IAssignmentDetails } from './eppo-client';
18-
19-
async function init(configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
20-
const apiEndpoints = new ApiEndpoints({
21-
baseUrl: 'http://127.0.0.1:4000',
22-
queryParams: {
23-
apiKey: 'dummy',
24-
sdkName: 'js-client-sdk-common',
25-
sdkVersion: '1.0.0',
26-
},
27-
});
28-
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
29-
const configurationRequestor = new ConfigurationRequestor(
30-
httpClient,
31-
configurationStore,
32-
null,
33-
null,
34-
);
35-
await configurationRequestor.fetchAndStoreConfigurations();
36-
}
14+
import { initConfiguration } from './test-utils';
3715

3816
describe('EppoClient get*AssignmentDetails', () => {
3917
const testStart = Date.now();
@@ -50,7 +28,7 @@ describe('EppoClient get*AssignmentDetails', () => {
5028
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
5129

5230
beforeAll(async () => {
53-
await init(storage);
31+
await initConfiguration(storage);
5432
});
5533

5634
it('should set the details for a matched rule', () => {
@@ -307,7 +285,7 @@ describe('EppoClient get*AssignmentDetails', () => {
307285
});
308286
}) as jest.Mock;
309287

310-
await init(storage);
288+
await initConfiguration(storage);
311289
});
312290

313291
afterAll(() => {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers';
2+
import * as applicationLogger from '../application-logger';
3+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
4+
import { Flag, ObfuscatedFlag } from '../interfaces';
5+
6+
import EppoClient, { IFlagExperiment } from './eppo-client';
7+
import { initConfiguration } from './test-utils';
8+
9+
type Container = { name: string };
10+
11+
describe('getExperimentContainer', () => {
12+
global.fetch = jest.fn(() => {
13+
const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE);
14+
return Promise.resolve({
15+
ok: true,
16+
status: 200,
17+
json: () => Promise.resolve(ufc),
18+
});
19+
}) as jest.Mock;
20+
21+
const controlContainer: Container = { name: 'Control Container' };
22+
const variation1Container: Container = { name: 'Variation 1 Container' };
23+
const variation2Container: Container = { name: 'Variation 2 Container' };
24+
const variation3Container: Container = { name: 'Variation 3 Container' };
25+
26+
let client: EppoClient;
27+
let flagExperiment: IFlagExperiment<Container>;
28+
let getStringAssignmentSpy: jest.SpyInstance;
29+
let loggerWarnSpy: jest.SpyInstance;
30+
31+
beforeEach(async () => {
32+
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
33+
await initConfiguration(storage);
34+
client = new EppoClient(storage);
35+
client.setIsGracefulFailureMode(true);
36+
flagExperiment = {
37+
flagKey: 'my-key',
38+
controlVariation: controlContainer,
39+
treatmentVariations: [variation1Container, variation2Container, variation3Container],
40+
};
41+
getStringAssignmentSpy = jest.spyOn(client, 'getStringAssignment');
42+
loggerWarnSpy = jest.spyOn(applicationLogger.logger, 'warn');
43+
});
44+
45+
afterAll(() => {
46+
getStringAssignmentSpy.mockRestore();
47+
loggerWarnSpy.mockRestore();
48+
});
49+
50+
it('should return the right container when a variation is assigned', async () => {
51+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('variation-2');
52+
expect(client.getExperimentContainer(flagExperiment, 'subject-key', {})).toEqual(
53+
variation2Container,
54+
);
55+
56+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('variation-3');
57+
expect(client.getExperimentContainer(flagExperiment, 'subject-key', {})).toEqual(
58+
variation3Container,
59+
);
60+
});
61+
62+
it('should return the right container when control is assigned', async () => {
63+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('control');
64+
expect(client.getExperimentContainer(flagExperiment, 'subject-key', {})).toEqual(
65+
controlContainer,
66+
);
67+
expect(loggerWarnSpy).not.toHaveBeenCalled();
68+
});
69+
70+
it('should default to the control container if an unknown variation is assigned', async () => {
71+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('adsfsadfsadf');
72+
expect(client.getExperimentContainer(flagExperiment, 'subject-key', {})).toEqual(
73+
controlContainer,
74+
);
75+
expect(loggerWarnSpy).toHaveBeenCalled();
76+
});
77+
78+
it('should default to the control container if an out-of-bounds variation is assigned', async () => {
79+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('variation-9');
80+
expect(client.getExperimentContainer(flagExperiment, 'subject-key', {})).toEqual(
81+
controlContainer,
82+
);
83+
expect(loggerWarnSpy).toHaveBeenCalled();
84+
});
85+
});

src/client/eppo-client.spec.ts

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,24 @@ import { times } from 'lodash';
22
import * as td from 'testdouble';
33

44
import {
5+
ASSIGNMENT_TEST_DATA_DIR,
56
IAssignmentTestCase,
67
MOCK_UFC_RESPONSE_FILE,
78
OBFUSCATED_MOCK_UFC_RESPONSE_FILE,
89
SubjectTestCase,
910
getTestAssignments,
1011
readMockUFCResponse,
11-
validateTestAssignments,
1212
testCasesByFileName,
13-
ASSIGNMENT_TEST_DATA_DIR,
13+
validateTestAssignments,
1414
} from '../../test/testHelpers';
15-
import ApiEndpoints from '../api-endpoints';
1615
import { IAssignmentLogger } from '../assignment-logger';
17-
import ConfigurationRequestor from '../configuration-requestor';
1816
import { IConfigurationStore } from '../configuration-store/configuration-store';
1917
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
2018
import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
21-
import FetchHttpClient from '../http-client';
2219
import { Flag, ObfuscatedFlag, VariationType } from '../interfaces';
2320

2421
import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client';
25-
26-
export async function init(configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
27-
const apiEndpoints = new ApiEndpoints({
28-
baseUrl: 'http://127.0.0.1:4000',
29-
queryParams: {
30-
apiKey: 'dummy',
31-
sdkName: 'js-client-sdk-common',
32-
sdkVersion: '1.0.0',
33-
},
34-
});
35-
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
36-
const configurationRequestor = new ConfigurationRequestor(
37-
httpClient,
38-
configurationStore,
39-
// Leave bandit stores empty for this test
40-
null,
41-
null,
42-
);
43-
await configurationRequestor.fetchAndStoreConfigurations();
44-
}
22+
import { initConfiguration } from './test-utils';
4523

4624
describe('EppoClient E2E test', () => {
4725
global.fetch = jest.fn(() => {
@@ -56,7 +34,7 @@ describe('EppoClient E2E test', () => {
5634
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
5735

5836
beforeAll(async () => {
59-
await init(storage);
37+
await initConfiguration(storage);
6038
});
6139

6240
const flagKey = 'mock-flag';
@@ -211,7 +189,7 @@ describe('EppoClient E2E test', () => {
211189
});
212190
}) as jest.Mock;
213191

214-
await init(storage);
192+
await initConfiguration(storage);
215193
});
216194

217195
afterAll(() => {
@@ -260,7 +238,7 @@ describe('EppoClient E2E test', () => {
260238
});
261239
}) as jest.Mock;
262240

263-
await init(storage);
241+
await initConfiguration(storage);
264242
});
265243

266244
afterAll(() => {

src/client/eppo-client.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export type FlagConfigurationRequestParameters = {
6868
skipInitialPoll?: boolean;
6969
};
7070

71+
export interface IFlagExperiment<T> {
72+
flagKey: string;
73+
controlVariation: T;
74+
treatmentVariations: Array<T>;
75+
}
76+
7177
export default class EppoClient {
7278
private readonly queuedAssignmentEvents: IAssignmentEvent[] = [];
7379
private assignmentLogger?: IAssignmentLogger;
@@ -524,6 +530,35 @@ export default class EppoClient {
524530
return { variation, action, evaluationDetails };
525531
}
526532

533+
/**
534+
* For use with 3rd party CMS tooling, such as the Contentful Eppo plugin
535+
*/
536+
public getExperimentContainer<T>(
537+
flagExperiment: IFlagExperiment<T>,
538+
subjectKey: string,
539+
subjectAttributes: Attributes,
540+
): T {
541+
const { flagKey, controlVariation, treatmentVariations } = flagExperiment;
542+
const assignment = this.getStringAssignment(flagKey, subjectKey, subjectAttributes, 'control');
543+
if (assignment === 'control') {
544+
return controlVariation;
545+
}
546+
if (!assignment.startsWith('variation-')) {
547+
logger.warn(
548+
`Variation ${assignment} cannot be mapped to a container. Defaulting to control variation.`,
549+
);
550+
return controlVariation;
551+
}
552+
const treatmentVariationIndex = Number.parseInt(assignment.split('-')[1]);
553+
if (treatmentVariationIndex > treatmentVariations.length) {
554+
logger.warn(
555+
`Selected treatment variation (${treatmentVariationIndex}) index is out of bounds. Defaulting to control variation.`,
556+
);
557+
return controlVariation;
558+
}
559+
return treatmentVariations[treatmentVariationIndex - 1];
560+
}
561+
527562
private evaluateBanditAction(
528563
flagKey: string,
529564
subjectKey: string,

src/client/test-utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import ApiEndpoints from '../api-endpoints';
2+
import ConfigurationRequestor from '../configuration-requestor';
3+
import { IConfigurationStore } from '../configuration-store/configuration-store';
4+
import FetchHttpClient from '../http-client';
5+
import { Flag, ObfuscatedFlag } from '../interfaces';
6+
7+
export async function initConfiguration(
8+
configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>,
9+
) {
10+
const apiEndpoints = new ApiEndpoints({
11+
baseUrl: 'http://127.0.0.1:4000',
12+
queryParams: {
13+
apiKey: 'dummy',
14+
sdkName: 'js-client-sdk-common',
15+
sdkVersion: '1.0.0',
16+
},
17+
});
18+
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
19+
const configurationRequestor = new ConfigurationRequestor(
20+
httpClient,
21+
configurationStore,
22+
null,
23+
null,
24+
);
25+
await configurationRequestor.fetchAndStoreConfigurations();
26+
}

0 commit comments

Comments
 (0)