@@ -11,6 +11,7 @@ import {
11
11
} from '@eppo/js-client-sdk-common' ;
12
12
import { BanditParameters , BanditVariation } from '@eppo/js-client-sdk-common/dist/interfaces' ;
13
13
import { ContextAttributes } from '@eppo/js-client-sdk-common/dist/types' ;
14
+ import { Attributes } from "@eppo/js-client-sdk-common/src/types" ;
14
15
import * as td from 'testdouble' ;
15
16
16
17
import apiServer , { TEST_BANDIT_API_KEY , TEST_SERVER_PORT } from '../test/mockApiServer' ;
@@ -27,6 +28,8 @@ import {
27
28
28
29
import { getInstance , IAssignmentEvent , IAssignmentLogger , init } from '.' ;
29
30
31
+ import SpyInstance = jest . SpyInstance ;
32
+
30
33
const { DEFAULT_POLL_INTERVAL_MS , POLL_JITTER_PCT } = constants ;
31
34
32
35
describe ( 'EppoClient E2E test' , ( ) => {
@@ -189,12 +192,12 @@ describe('EppoClient E2E test', () => {
189
192
it ( 'returns the default value when ufc config is absent' , ( ) => {
190
193
const mockConfigStore = td . object < IConfigurationStore < Flag > > ( ) ;
191
194
td . when ( mockConfigStore . get ( flagKey ) ) . thenReturn ( null ) ;
192
- const client = new EppoClient (
193
- mockConfigStore ,
194
- mockBanditVariationStore ,
195
- mockBanditModelStore ,
196
- requestParamsStub ,
197
- ) ;
195
+ const client = new EppoClient ( {
196
+ flagConfigurationStore : mockConfigStore ,
197
+ banditVariationConfigurationStore : mockBanditVariationStore ,
198
+ banditModelConfigurationStore : mockBanditModelStore ,
199
+ configurationRequestParameters : requestParamsStub ,
200
+ } ) ;
198
201
const assignment = client . getStringAssignment ( flagKey , 'subject-10' , { } , 'default-value' ) ;
199
202
expect ( assignment ) . toEqual ( 'default-value' ) ;
200
203
} ) ;
@@ -203,12 +206,12 @@ describe('EppoClient E2E test', () => {
203
206
const mockConfigStore = td . object < IConfigurationStore < Flag > > ( ) ;
204
207
td . when ( mockConfigStore . get ( flagKey ) ) . thenReturn ( mockUfcFlagConfig ) ;
205
208
const subjectAttributes = { foo : 3 } ;
206
- const client = new EppoClient (
207
- mockConfigStore ,
208
- mockBanditVariationStore ,
209
- mockBanditModelStore ,
210
- requestParamsStub ,
211
- ) ;
209
+ const client = new EppoClient ( {
210
+ flagConfigurationStore : mockConfigStore ,
211
+ banditVariationConfigurationStore : mockBanditVariationStore ,
212
+ banditModelConfigurationStore : mockBanditModelStore ,
213
+ configurationRequestParameters : requestParamsStub ,
214
+ } ) ;
212
215
const mockLogger = td . object < IAssignmentLogger > ( ) ;
213
216
client . setAssignmentLogger ( mockLogger ) ;
214
217
const assignment = client . getStringAssignment (
@@ -233,12 +236,12 @@ describe('EppoClient E2E test', () => {
233
236
const mockConfigStore = td . object < IConfigurationStore < Flag > > ( ) ;
234
237
td . when ( mockConfigStore . get ( flagKey ) ) . thenReturn ( mockUfcFlagConfig ) ;
235
238
const subjectAttributes = { foo : 3 } ;
236
- const client = new EppoClient (
237
- mockConfigStore ,
238
- mockBanditVariationStore ,
239
- mockBanditModelStore ,
240
- requestParamsStub ,
241
- ) ;
239
+ const client = new EppoClient ( {
240
+ flagConfigurationStore : mockConfigStore ,
241
+ banditVariationConfigurationStore : mockBanditVariationStore ,
242
+ banditModelConfigurationStore : mockBanditModelStore ,
243
+ configurationRequestParameters : requestParamsStub ,
244
+ } ) ;
242
245
const mockLogger = td . object < IAssignmentLogger > ( ) ;
243
246
td . when ( mockLogger . logAssignment ( td . matchers . anything ( ) ) ) . thenThrow (
244
247
new Error ( 'logging error' ) ,
@@ -312,6 +315,131 @@ describe('EppoClient E2E test', () => {
312
315
// Ensure that this test case correctly checked some test assignments
313
316
expect ( numAssignmentsChecked ) . toBeGreaterThan ( 0 ) ;
314
317
} ) ;
318
+
319
+
320
+ describe ( 'Bandit assignment cache' , ( ) => {
321
+ const flagKey = 'banner_bandit_flag' ; // piggyback off a shared test data flag
322
+ const bobKey = 'bob' ;
323
+ const bobAttributes : Attributes = { age : 25 , country : 'USA' , gender_identity : 'female' } ;
324
+ const bobActions : Record < string , Attributes > = {
325
+ nike : { brand_affinity : 1.5 , loyalty_tier : 'silver' } ,
326
+ adidas : { brand_affinity : - 1.0 , loyalty_tier : 'bronze' } ,
327
+ reebok : { brand_affinity : 0.5 , loyalty_tier : 'gold' } ,
328
+ } ;
329
+
330
+ const aliceKey = 'alice' ;
331
+ const aliceAttributes : Attributes = { age : 25 , country : 'USA' , gender_identity : 'female' } ;
332
+ const aliceActions : Record < string , Attributes > = {
333
+ nike : { brand_affinity : 1.5 , loyalty_tier : 'silver' } ,
334
+ adidas : { brand_affinity : - 1.0 , loyalty_tier : 'bronze' } ,
335
+ reebok : { brand_affinity : 0.5 , loyalty_tier : 'gold' } ,
336
+ }
337
+ const charlieKey = 'charlie' ;
338
+ const charlieAttributes : Attributes = { age : 25 , country : 'USA' , gender_identity : 'female' } ;
339
+ const charlieActions : Record < string , Attributes > = {
340
+ nike : { brand_affinity : 1.0 , loyalty_tier : 'gold' } ,
341
+ adidas : { brand_affinity : 1.0 , loyalty_tier : 'silver' } ,
342
+ puma : { } ,
343
+ }
344
+
345
+ let banditLoggerSpy : SpyInstance < void , [ banditEvent : IBanditEvent ] > ;
346
+ const defaultBanditCacheTTL = 600_000
347
+
348
+ beforeAll ( async ( ) => {
349
+ const dummyBanditLogger : IBanditLogger = {
350
+ logBanditAction ( banditEvent : IBanditEvent ) {
351
+ console . log (
352
+ `Bandit ${ banditEvent . bandit } assigned ${ banditEvent . subject } the action ${ banditEvent . action } ` ,
353
+ ) ;
354
+ } ,
355
+ } ;
356
+ banditLoggerSpy = jest . spyOn ( dummyBanditLogger , 'logBanditAction' ) ;
357
+ await init ( {
358
+ apiKey : TEST_BANDIT_API_KEY , // Flag to dummy test server we want bandit-related files
359
+ baseUrl : `http://127.0.0.1:${ TEST_SERVER_PORT } ` ,
360
+ assignmentLogger : mockLogger ,
361
+ banditLogger : dummyBanditLogger ,
362
+ } ) ;
363
+ } ) ;
364
+
365
+ it ( 'Should not log bandit assignment if cached version is still valid' , async ( ) => {
366
+ const client = getInstance ( ) ;
367
+ client . useExpiringInMemoryBanditAssignmentCache ( 2 ) ;
368
+
369
+ // Let's say someone is rage refreshing - we want to log assignment only once
370
+ for ( const _ of Array ( 3 ) . keys ( ) ) {
371
+ client . getBanditAction (
372
+ flagKey ,
373
+ bobKey ,
374
+ bobAttributes ,
375
+ bobActions ,
376
+ 'default' ,
377
+ ) ;
378
+ }
379
+ expect ( banditLoggerSpy ) . toHaveBeenCalledTimes ( 1 )
380
+ } ) ;
381
+ it ( 'Should log bandit assignment if cached entry is expired' , async ( ) => {
382
+ jest . useFakeTimers ( ) ;
383
+
384
+ const client = getInstance ( ) ;
385
+ client . useExpiringInMemoryBanditAssignmentCache ( 2 ) ;
386
+
387
+ client . getBanditAction (
388
+ flagKey ,
389
+ bobKey ,
390
+ bobAttributes ,
391
+ bobActions ,
392
+ 'default' ,
393
+ ) ;
394
+ jest . advanceTimersByTime ( defaultBanditCacheTTL ) ;
395
+ client . getBanditAction (
396
+ flagKey ,
397
+ bobKey ,
398
+ bobAttributes ,
399
+ bobActions ,
400
+ 'default' ,
401
+ ) ;
402
+ expect ( banditLoggerSpy ) . toHaveBeenCalledTimes ( 2 )
403
+ } )
404
+
405
+ it ( 'Should invalidate least used cache entry if cache reaches max size' , async ( ) => {
406
+ const client = getInstance ( ) ;
407
+ client . useExpiringInMemoryBanditAssignmentCache ( 2 ) ;
408
+
409
+ client . getBanditAction (
410
+ flagKey ,
411
+ bobKey ,
412
+ bobAttributes ,
413
+ bobActions ,
414
+ 'default' ,
415
+ ) ;
416
+ client . getBanditAction (
417
+ flagKey ,
418
+ aliceKey ,
419
+ aliceAttributes ,
420
+ aliceActions ,
421
+ 'default' ,
422
+ ) ;
423
+ client . getBanditAction (
424
+ flagKey ,
425
+ charlieKey ,
426
+ charlieAttributes ,
427
+ charlieActions ,
428
+ 'default'
429
+ ) ;
430
+ // even though bob was called 2nd time within cache validity time
431
+ // we expect assignment to be logged because max cache size is 2
432
+ // and currently storing alice and charlie
433
+ client . getBanditAction (
434
+ flagKey ,
435
+ bobKey ,
436
+ bobAttributes ,
437
+ bobActions ,
438
+ 'default' ,
439
+ ) ;
440
+ expect ( banditLoggerSpy ) . toHaveBeenCalledTimes ( 4 ) ;
441
+ } ) ;
442
+ } ) ;
315
443
} ) ;
316
444
317
445
describe ( 'initialization errors' , ( ) => {
0 commit comments