@@ -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
const apiKey = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk' ;
@@ -313,6 +316,90 @@ describe('EppoClient E2E test', () => {
313
316
// Ensure that this test case correctly checked some test assignments
314
317
expect ( numAssignmentsChecked ) . toBeGreaterThan ( 0 ) ;
315
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 ( flagKey , bobKey , bobAttributes , bobActions , 'default' ) ;
372
+ }
373
+ expect ( banditLoggerSpy ) . toHaveBeenCalledTimes ( 1 ) ;
374
+ } ) ;
375
+ it ( 'Should log bandit assignment if cached entry is expired' , async ( ) => {
376
+ jest . useFakeTimers ( ) ;
377
+ banditLoggerSpy . mockReset ( ) ;
378
+
379
+ const client = getInstance ( ) ;
380
+ client . useExpiringInMemoryBanditAssignmentCache ( 2 ) ;
381
+
382
+ client . getBanditAction ( flagKey , bobKey , bobAttributes , bobActions , 'default' ) ;
383
+ jest . advanceTimersByTime ( defaultBanditCacheTTL ) ;
384
+ client . getBanditAction ( flagKey , bobKey , bobAttributes , bobActions , 'default' ) ;
385
+ expect ( banditLoggerSpy ) . toHaveBeenCalledTimes ( 2 ) ;
386
+ } ) ;
387
+
388
+ it ( 'Should invalidate least used cache entry if cache reaches max size' , async ( ) => {
389
+ banditLoggerSpy . mockReset ( ) ;
390
+ const client = getInstance ( ) ;
391
+ client . useExpiringInMemoryBanditAssignmentCache ( 2 ) ;
392
+
393
+ client . getBanditAction ( flagKey , bobKey , bobAttributes , bobActions , 'default' ) ;
394
+ client . getBanditAction ( flagKey , aliceKey , aliceAttributes , aliceActions , 'default' ) ;
395
+ client . getBanditAction ( flagKey , charlieKey , charlieAttributes , charlieActions , 'default' ) ;
396
+ // even though bob was called 2nd time within cache validity time
397
+ // we expect assignment to be logged because max cache size is 2
398
+ // and currently storing alice and charlie
399
+ client . getBanditAction ( flagKey , bobKey , bobAttributes , bobActions , 'default' ) ;
400
+ expect ( banditLoggerSpy ) . toHaveBeenCalledTimes ( 4 ) ;
401
+ } ) ;
402
+ } ) ;
316
403
} ) ;
317
404
318
405
describe ( 'initialization errors' , ( ) => {
0 commit comments