@@ -6,15 +6,22 @@ import {
6
6
IConfigurationStore ,
7
7
Flag ,
8
8
VariationType ,
9
+ IBanditEvent ,
10
+ IBanditLogger ,
9
11
} from '@eppo/js-client-sdk-common' ;
12
+ import { BanditParameters , BanditVariation } from '@eppo/js-client-sdk-common/dist/interfaces' ;
13
+ import { ContextAttributes } from '@eppo/js-client-sdk-common/dist/types' ;
10
14
import * as td from 'testdouble' ;
11
15
12
- import apiServer , { TEST_SERVER_PORT } from '../test/mockApiServer' ;
16
+ import apiServer , { TEST_BANDIT_API_KEY , TEST_SERVER_PORT } from '../test/mockApiServer' ;
13
17
import {
18
+ ASSIGNMENT_TEST_DATA_DIR ,
19
+ BANDIT_TEST_DATA_DIR ,
20
+ BanditTestCase ,
14
21
getTestAssignments ,
15
22
IAssignmentTestCase ,
16
- readAssignmentTestData ,
17
23
SubjectTestCase ,
24
+ testCasesByFileName ,
18
25
validateTestAssignments ,
19
26
} from '../test/testHelpers' ;
20
27
@@ -29,6 +36,11 @@ describe('EppoClient E2E test', () => {
29
36
} ,
30
37
} ;
31
38
39
+ // These two stores should not be used as this file doesn't test bandits, but we want them to be defined so bandit
40
+ // functionality is still "on" for the client when we explicitly instantiate the client (vs. using init())
41
+ const mockBanditVariationStore = td . object < IConfigurationStore < BanditVariation [ ] > > ( ) ;
42
+ const mockBanditModelStore = td . object < IConfigurationStore < BanditParameters > > ( ) ;
43
+
32
44
const flagKey = 'mock-experiment' ;
33
45
34
46
// Configuration for a single flag within the UFC.
@@ -139,45 +151,50 @@ describe('EppoClient E2E test', () => {
139
151
} ) ;
140
152
} ) ;
141
153
142
- describe ( 'UFC General Test Cases' , ( ) => {
143
- it . each ( readAssignmentTestData ( ) ) (
144
- 'test variation assignment splits' ,
145
- async ( { flag, variationType, defaultValue, subjects } : IAssignmentTestCase ) => {
146
- const client = getInstance ( ) ;
147
-
148
- let assignments : {
149
- subject : SubjectTestCase ;
150
- assignment : string | boolean | number | object ;
151
- } [ ] = [ ] ;
152
-
153
- const typeAssignmentFunctions = {
154
- [ VariationType . BOOLEAN ] : client . getBoolAssignment . bind ( client ) ,
155
- [ VariationType . NUMERIC ] : client . getNumericAssignment . bind ( client ) ,
156
- [ VariationType . INTEGER ] : client . getIntegerAssignment . bind ( client ) ,
157
- [ VariationType . STRING ] : client . getStringAssignment . bind ( client ) ,
158
- [ VariationType . JSON ] : client . getJSONAssignment . bind ( client ) ,
159
- } ;
154
+ describe ( 'Shared UFC General Test Cases' , ( ) => {
155
+ const testCases = testCasesByFileName < IAssignmentTestCase > ( ASSIGNMENT_TEST_DATA_DIR ) ;
156
+
157
+ it . each ( Object . keys ( testCases ) ) ( 'test variation assignment splits - %s' , async ( fileName ) => {
158
+ const { flag, variationType, defaultValue, subjects } = testCases [ fileName ] ;
159
+ const client = getInstance ( ) ;
160
+
161
+ let assignments : {
162
+ subject : SubjectTestCase ;
163
+ assignment : string | boolean | number | object ;
164
+ } [ ] = [ ] ;
165
+
166
+ const typeAssignmentFunctions = {
167
+ [ VariationType . BOOLEAN ] : client . getBooleanAssignment . bind ( client ) ,
168
+ [ VariationType . NUMERIC ] : client . getNumericAssignment . bind ( client ) ,
169
+ [ VariationType . INTEGER ] : client . getIntegerAssignment . bind ( client ) ,
170
+ [ VariationType . STRING ] : client . getStringAssignment . bind ( client ) ,
171
+ [ VariationType . JSON ] : client . getJSONAssignment . bind ( client ) ,
172
+ } ;
173
+
174
+ const assignmentFn = typeAssignmentFunctions [ variationType ] ;
175
+ if ( ! assignmentFn ) {
176
+ throw new Error ( `Unknown variation type: ${ variationType } ` ) ;
177
+ }
160
178
161
- const assignmentFn = typeAssignmentFunctions [ variationType ] ;
162
- if ( ! assignmentFn ) {
163
- throw new Error ( `Unknown variation type: ${ variationType } ` ) ;
164
- }
179
+ assignments = getTestAssignments (
180
+ { flag, variationType, defaultValue, subjects } ,
181
+ assignmentFn ,
182
+ false ,
183
+ ) ;
165
184
166
- assignments = getTestAssignments (
167
- { flag, variationType, defaultValue, subjects } ,
168
- assignmentFn ,
169
- false ,
170
- ) ;
171
-
172
- validateTestAssignments ( assignments , flag ) ;
173
- } ,
174
- ) ;
185
+ validateTestAssignments ( assignments , flag ) ;
186
+ } ) ;
175
187
} ) ;
176
188
177
189
it ( 'returns the default value when ufc config is absent' , ( ) => {
178
190
const mockConfigStore = td . object < IConfigurationStore < Flag > > ( ) ;
179
191
td . when ( mockConfigStore . get ( flagKey ) ) . thenReturn ( null ) ;
180
- const client = new EppoClient ( mockConfigStore , requestParamsStub ) ;
192
+ const client = new EppoClient (
193
+ mockConfigStore ,
194
+ mockBanditVariationStore ,
195
+ mockBanditModelStore ,
196
+ requestParamsStub ,
197
+ ) ;
181
198
const assignment = client . getStringAssignment ( flagKey , 'subject-10' , { } , 'default-value' ) ;
182
199
expect ( assignment ) . toEqual ( 'default-value' ) ;
183
200
} ) ;
@@ -186,9 +203,14 @@ describe('EppoClient E2E test', () => {
186
203
const mockConfigStore = td . object < IConfigurationStore < Flag > > ( ) ;
187
204
td . when ( mockConfigStore . get ( flagKey ) ) . thenReturn ( mockUfcFlagConfig ) ;
188
205
const subjectAttributes = { foo : 3 } ;
189
- const client = new EppoClient ( mockConfigStore , requestParamsStub ) ;
206
+ const client = new EppoClient (
207
+ mockConfigStore ,
208
+ mockBanditVariationStore ,
209
+ mockBanditModelStore ,
210
+ requestParamsStub ,
211
+ ) ;
190
212
const mockLogger = td . object < IAssignmentLogger > ( ) ;
191
- client . setLogger ( mockLogger ) ;
213
+ client . setAssignmentLogger ( mockLogger ) ;
192
214
const assignment = client . getStringAssignment (
193
215
flagKey ,
194
216
'subject-10' ,
@@ -211,12 +233,17 @@ describe('EppoClient E2E test', () => {
211
233
const mockConfigStore = td . object < IConfigurationStore < Flag > > ( ) ;
212
234
td . when ( mockConfigStore . get ( flagKey ) ) . thenReturn ( mockUfcFlagConfig ) ;
213
235
const subjectAttributes = { foo : 3 } ;
214
- const client = new EppoClient ( mockConfigStore , requestParamsStub ) ;
236
+ const client = new EppoClient (
237
+ mockConfigStore ,
238
+ mockBanditVariationStore ,
239
+ mockBanditModelStore ,
240
+ requestParamsStub ,
241
+ ) ;
215
242
const mockLogger = td . object < IAssignmentLogger > ( ) ;
216
243
td . when ( mockLogger . logAssignment ( td . matchers . anything ( ) ) ) . thenThrow (
217
244
new Error ( 'logging error' ) ,
218
245
) ;
219
- client . setLogger ( mockLogger ) ;
246
+ client . setAssignmentLogger ( mockLogger ) ;
220
247
const assignment = client . getStringAssignment (
221
248
flagKey ,
222
249
'subject-10' ,
@@ -227,6 +254,66 @@ describe('EppoClient E2E test', () => {
227
254
} ) ;
228
255
} ) ;
229
256
257
+ describe ( 'Shared Bandit Test Cases' , ( ) => {
258
+ beforeAll ( async ( ) => {
259
+ const dummyBanditLogger : IBanditLogger = {
260
+ logBanditAction ( banditEvent : IBanditEvent ) {
261
+ console . log (
262
+ `Bandit ${ banditEvent . bandit } assigned ${ banditEvent . subject } the action ${ banditEvent . action } ` ,
263
+ ) ;
264
+ } ,
265
+ } ;
266
+
267
+ await init ( {
268
+ apiKey : TEST_BANDIT_API_KEY , // Flag to dummy test server we want bandit-related files
269
+ baseUrl : `http://127.0.0.1:${ TEST_SERVER_PORT } ` ,
270
+ assignmentLogger : mockLogger ,
271
+ banditLogger : dummyBanditLogger ,
272
+ } ) ;
273
+ } ) ;
274
+
275
+ const testCases = testCasesByFileName < BanditTestCase > ( BANDIT_TEST_DATA_DIR ) ;
276
+
277
+ it . each ( Object . keys ( testCases ) ) ( 'Shared bandit test case - %s' , async ( fileName : string ) => {
278
+ const { flag : flagKey , defaultValue, subjects } = testCases [ fileName ] ;
279
+ let numAssignmentsChecked = 0 ;
280
+ subjects . forEach ( ( subject ) => {
281
+ // test files have actions as an array, so we convert them to a map as expected by the client
282
+ const actions : Record < string , ContextAttributes > = { } ;
283
+ subject . actions . forEach ( ( action ) => {
284
+ actions [ action . actionKey ] = {
285
+ numericAttributes : action . numericAttributes ,
286
+ categoricalAttributes : action . categoricalAttributes ,
287
+ } ;
288
+ } ) ;
289
+
290
+ // get the bandit assignment for the test case
291
+ const banditAssignment = getInstance ( ) . getBanditAction (
292
+ flagKey ,
293
+ subject . subjectKey ,
294
+ subject . subjectAttributes ,
295
+ actions ,
296
+ defaultValue ,
297
+ ) ;
298
+
299
+ // Do this check in addition to assertions to provide helpful information on exactly which
300
+ // evaluation failed to produce an expected result
301
+ if (
302
+ banditAssignment . variation !== subject . assignment . variation ||
303
+ banditAssignment . action !== subject . assignment . action
304
+ ) {
305
+ console . error ( `Unexpected result for flag ${ flagKey } and subject ${ subject . subjectKey } ` ) ;
306
+ }
307
+
308
+ expect ( banditAssignment . variation ) . toBe ( subject . assignment . variation ) ;
309
+ expect ( banditAssignment . action ) . toBe ( subject . assignment . action ) ;
310
+ numAssignmentsChecked += 1 ;
311
+ } ) ;
312
+ // Ensure that this test case correctly checked some test assignments
313
+ expect ( numAssignmentsChecked ) . toBeGreaterThan ( 0 ) ;
314
+ } ) ;
315
+ } ) ;
316
+
230
317
describe ( 'initialization errors' , ( ) => {
231
318
const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT ;
232
319
const mockConfigResponse = {
@@ -236,9 +323,9 @@ describe('EppoClient E2E test', () => {
236
323
} ;
237
324
238
325
it ( 'retries initial configuration request before resolving' , async ( ) => {
239
- td . replace ( HttpClient . prototype , 'get ' ) ;
326
+ td . replace ( HttpClient . prototype , 'getUniversalFlagConfiguration ' ) ;
240
327
let callCount = 0 ;
241
- td . when ( HttpClient . prototype . get ( td . matchers . anything ( ) ) ) . thenDo ( ( ) => {
328
+ td . when ( HttpClient . prototype . getUniversalFlagConfiguration ( ) ) . thenDo ( ( ) => {
242
329
if ( ++ callCount === 1 ) {
243
330
// Throw an error for the first call
244
331
throw new Error ( 'Intentional Thrown Error For Test' ) ;
@@ -266,9 +353,9 @@ describe('EppoClient E2E test', () => {
266
353
} ) ;
267
354
268
355
it ( 'gives up initial request and throws error after hitting max retries' , async ( ) => {
269
- td . replace ( HttpClient . prototype , 'get ' ) ;
356
+ td . replace ( HttpClient . prototype , 'getUniversalFlagConfiguration ' ) ;
270
357
let callCount = 0 ;
271
- td . when ( HttpClient . prototype . get ( td . matchers . anything ( ) ) ) . thenDo ( async ( ) => {
358
+ td . when ( HttpClient . prototype . getUniversalFlagConfiguration ( ) ) . thenDo ( async ( ) => {
272
359
callCount += 1 ;
273
360
throw new Error ( 'Intentional Thrown Error For Test' ) ;
274
361
} ) ;
@@ -298,9 +385,9 @@ describe('EppoClient E2E test', () => {
298
385
} ) ;
299
386
300
387
it ( 'gives up initial request but still polls later if configured to do so' , async ( ) => {
301
- td . replace ( HttpClient . prototype , 'get ' ) ;
388
+ td . replace ( HttpClient . prototype , 'getUniversalFlagConfiguration ' ) ;
302
389
let callCount = 0 ;
303
- td . when ( HttpClient . prototype . get ( td . matchers . anything ( ) ) ) . thenDo ( ( ) => {
390
+ td . when ( HttpClient . prototype . getUniversalFlagConfiguration ( ) ) . thenDo ( ( ) => {
304
391
if ( ++ callCount <= 2 ) {
305
392
// Throw an error for the first call
306
393
throw new Error ( 'Intentional Thrown Error For Test' ) ;
0 commit comments