Skip to content

Commit e5e5b9f

Browse files
committed
feat: make EppoClient handle precomputed bandits
1 parent 311f61f commit e5e5b9f

10 files changed

+586
-2293
lines changed

src/client/eppo-client-with-bandits.spec.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ import ApiEndpoints from '../api-endpoints';
1313
import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger';
1414
import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator';
1515
import { IBanditEvent, IBanditLogger } from '../bandit-logger';
16-
import ConfigurationRequestor from '../configuration-requestor';
17-
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
1816
import {
19-
IConfigurationWire,
2017
IPrecomputedConfiguration,
2118
IObfuscatedPrecomputedConfigurationResponse,
2219
} from '../configuration-wire/configuration-wire-types';
@@ -25,8 +22,6 @@ import {
2522
AllocationEvaluationCode,
2623
IFlagEvaluationDetails,
2724
} from '../flag-evaluation-details-builder';
28-
import FetchHttpClient from '../http-client';
29-
import { BanditVariation, BanditParameters, Flag } from '../interfaces';
3025
import { attributeEncodeBase64 } from '../obfuscation';
3126
import { Attributes, BanditActions, ContextAttributes } from '../types';
3227

@@ -511,19 +506,59 @@ describe('EppoClient Bandits E2E test', () => {
511506
mockEvaluateFlag = jest
512507
.spyOn(Evaluator.prototype, 'evaluateFlag')
513508
.mockImplementation(() => {
509+
const evaluationDetails = {
510+
flagEvaluationCode: 'MATCH' as const,
511+
flagEvaluationDescription: 'Mocked evaluation',
512+
configFetchedAt: new Date().toISOString(),
513+
configPublishedAt: new Date().toISOString(),
514+
environmentName: 'test',
515+
variationKey: variationToReturn,
516+
variationValue: variationToReturn,
517+
banditKey: null,
518+
banditAction: null,
519+
matchedRule: null,
520+
matchedAllocation: {
521+
key: 'mock-allocation',
522+
allocationEvaluationCode: AllocationEvaluationCode.MATCH,
523+
orderPosition: 1,
524+
},
525+
unmatchedAllocations: [],
526+
unevaluatedAllocations: [],
527+
};
528+
514529
return {
515-
flagKey,
516-
subjectKey,
517-
subjectAttributes,
518-
allocationKey: 'mock-allocation',
519-
variation: { key: variationToReturn, value: variationToReturn },
520-
extraLogging: {},
521-
doLog: true,
522-
flagEvaluationDetails: {
523-
flagEvaluationCode: 'MATCH',
524-
flagEvaluationDescription: 'Mocked evaluation',
530+
assignmentDetails: {
531+
flagKey,
532+
format: 'SERVER',
533+
subjectKey,
534+
subjectAttributes,
535+
allocationKey: 'mock-allocation',
536+
variation: { key: variationToReturn, value: variationToReturn },
537+
extraLogging: {},
538+
doLog: true,
539+
entityId: null,
540+
evaluationDetails,
525541
},
526-
} as FlagEvaluation;
542+
assignmentEvent: {
543+
allocation: 'mock-allocation',
544+
experiment: `${flagKey}-mock-allocation`,
545+
featureFlag: flagKey,
546+
format: 'SERVER',
547+
variation: variationToReturn,
548+
subject: subjectKey,
549+
timestamp: new Date().toISOString(),
550+
subjectAttributes,
551+
metaData: {
552+
obfuscated: false,
553+
sdkLanguage: 'javascript',
554+
sdkLibVersion: '1.0.0',
555+
sdkName: 'js-client-sdk-common',
556+
sdkVersion: '1.0.0',
557+
},
558+
evaluationDetails,
559+
entityId: null,
560+
}
561+
};
527562
});
528563

529564
mockEvaluateBandit = jest
@@ -662,7 +697,7 @@ describe('EppoClient Bandits E2E test', () => {
662697
salt,
663698
);
664699

665-
const { precomputed } = JSON.parse(precomputedResults) as IConfigurationWire;
700+
const { precomputed } = JSON.parse(precomputedResults);
666701
if (!precomputed) {
667702
fail('precomputed result was not parsed');
668703
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import * as td from 'testdouble';
2+
3+
import {
4+
MOCK_PRECOMPUTED_WIRE_FILE,
5+
MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE,
6+
readMockConfigurationWireResponse,
7+
} from '../../test/testHelpers';
8+
import { logger } from '../application-logger';
9+
import { IAssignmentLogger } from '../assignment-logger';
10+
import { IBanditLogger } from '../bandit-logger';
11+
import { Configuration } from '../configuration';
12+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
13+
import { FormatEnum, VariationType, Variation } from '../interfaces';
14+
import { BanditActions } from '../types';
15+
16+
import EppoClient from './eppo-client';
17+
18+
describe('EppoClient Precomputed Mode', () => {
19+
// Read both configurations for test reference
20+
const precomputedConfigurationWire = readMockConfigurationWireResponse(MOCK_PRECOMPUTED_WIRE_FILE);
21+
const initialConfiguration = Configuration.fromString(precomputedConfigurationWire);
22+
23+
// We only use deobfuscated configuration as a reference, not for creating a client
24+
const deobfuscatedPrecomputedWire = readMockConfigurationWireResponse(MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE);
25+
26+
let client: EppoClient;
27+
let mockAssignmentLogger: jest.Mocked<IAssignmentLogger>;
28+
let mockBanditLogger: jest.Mocked<IBanditLogger>;
29+
30+
beforeEach(() => {
31+
mockAssignmentLogger = { logAssignment: jest.fn() } as jest.Mocked<IAssignmentLogger>;
32+
mockBanditLogger = { logBanditAction: jest.fn() } as jest.Mocked<IBanditLogger>;
33+
34+
// Create EppoClient with precomputed configuration
35+
client = new EppoClient({
36+
sdkKey: 'test-key',
37+
sdkName: 'test-sdk',
38+
sdkVersion: '1.0.0',
39+
configuration: {
40+
initialConfiguration,
41+
initializationStrategy: 'none',
42+
enablePolling: false,
43+
},
44+
});
45+
46+
client.setAssignmentLogger(mockAssignmentLogger);
47+
client.setBanditLogger(mockBanditLogger);
48+
});
49+
50+
it('correctly evaluates string flag', () => {
51+
const result = client.getStringAssignment('string-flag', 'test-subject-key', {}, 'default');
52+
expect(result).toBe('red');
53+
expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1);
54+
});
55+
56+
it('correctly evaluates boolean flag', () => {
57+
const result = client.getBooleanAssignment('boolean-flag', 'test-subject-key', {}, false);
58+
expect(result).toBe(true);
59+
expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it('correctly evaluates integer flag', () => {
63+
const result = client.getIntegerAssignment('integer-flag', 'test-subject-key', {}, 0);
64+
expect(result).toBe(42);
65+
expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1);
66+
});
67+
68+
it('correctly evaluates numeric flag', () => {
69+
const result = client.getNumericAssignment('numeric-flag', 'test-subject-key', {}, 0);
70+
expect(result).toBe(3.14);
71+
expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1);
72+
});
73+
74+
it('correctly evaluates JSON flag', () => {
75+
const result = client.getJSONAssignment('json-flag', 'test-subject-key', {}, {});
76+
expect(result).toEqual({ key: 'value', number: 123 });
77+
expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1);
78+
});
79+
80+
it('correctly evaluates flag with extra logging', () => {
81+
const result = client.getStringAssignment('string-flag-with-extra-logging', 'test-subject-key', {}, 'default');
82+
expect(result).toBe('red');
83+
expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1);
84+
});
85+
86+
it('logs bandit evaluation for flag with bandit data', () => {
87+
const banditActions = {
88+
'show_red_button': {
89+
expectedConversion: 0.23,
90+
expectedRevenue: 15.75,
91+
category: 'promotion',
92+
placement: 'home_screen'
93+
}
94+
};
95+
96+
const result = client.getBanditAction('string-flag', 'test-subject-key', {}, banditActions, 'default');
97+
98+
expect(result.variation).toBe('red');
99+
expect(result.action).toBe('show_red_button');
100+
expect(mockBanditLogger.logBanditAction).toHaveBeenCalledTimes(1);
101+
102+
const call = mockBanditLogger.logBanditAction.mock.calls[0][0];
103+
expect(call.bandit).toBe('recommendation-model-v1');
104+
expect(call.action).toBe('show_red_button');
105+
expect(call.modelVersion).toBe('v2.3.1');
106+
expect(call.actionProbability).toBe(0.85);
107+
expect(call.optimalityGap).toBe(0.12);
108+
});
109+
110+
it('returns default values for nonexistent flags', () => {
111+
const stringResult = client.getStringAssignment('nonexistent-flag', 'test-subject-key', {}, 'default-string');
112+
expect(stringResult).toBe('default-string');
113+
114+
const boolResult = client.getBooleanAssignment('nonexistent-flag', 'test-subject-key', {}, true);
115+
expect(boolResult).toBe(true);
116+
117+
const intResult = client.getIntegerAssignment('nonexistent-flag', 'test-subject-key', {}, 100);
118+
expect(intResult).toBe(100);
119+
});
120+
121+
it('correctly handles assignment details', () => {
122+
const details = client.getStringAssignmentDetails('string-flag', 'test-subject-key', {}, 'default');
123+
124+
expect(details.variation).toBe('red');
125+
expect(details.evaluationDetails.variationKey).toBe('variation-123');
126+
127+
// Assignment should be logged
128+
expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1);
129+
const call = mockAssignmentLogger.logAssignment.mock.calls[0][0];
130+
expect(call.allocation).toBe('allocation-123');
131+
expect(call.featureFlag).toBe('string-flag');
132+
expect(call.subject).toBe('test-subject-key');
133+
});
134+
});

0 commit comments

Comments
 (0)