Skip to content

Commit 17b7ded

Browse files
beefed up tests for bandits polling
1 parent 7c1d783 commit 17b7ded

File tree

1 file changed

+201
-85
lines changed

1 file changed

+201
-85
lines changed

src/configuration-requestor.spec.ts

Lines changed: 201 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ import ApiEndpoints from './api-endpoints';
99
import ConfigurationRequestor from './configuration-requestor';
1010
import { IConfigurationStore } from './configuration-store/configuration-store';
1111
import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store';
12-
import FetchHttpClient, { IHttpClient } from './http-client';
13-
import { BanditVariation, BanditParameters, Flag } from './interfaces';
12+
import FetchHttpClient, {
13+
IBanditParametersResponse,
14+
IHttpClient,
15+
IUniversalFlagConfigResponse,
16+
} from './http-client';
17+
import { BanditParameters, BanditVariation, Flag } from './interfaces';
1418

1519
describe('ConfigurationRequestor', () => {
1620
let flagStore: IConfigurationStore<Flag>;
@@ -111,105 +115,217 @@ describe('ConfigurationRequestor', () => {
111115
describe('Flags with bandits', () => {
112116
let fetchSpy: jest.Mock;
113117

114-
beforeAll(() => {
118+
function initiateFetchSpy(
119+
responseMockGenerator: (
120+
url: string,
121+
) => IUniversalFlagConfigResponse | IBanditParametersResponse,
122+
) {
115123
fetchSpy = jest.fn((url: string) => {
116-
const responseFile = url.includes('bandits')
117-
? MOCK_BANDIT_MODELS_RESPONSE_FILE
118-
: MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE;
119-
const response = readMockUFCResponse(responseFile);
120-
124+
const response = responseMockGenerator(url);
121125
return Promise.resolve({
122126
ok: true,
123127
status: 200,
124128
json: () => Promise.resolve(response),
125129
});
126130
}) as jest.Mock;
127131
global.fetch = fetchSpy;
128-
});
132+
}
129133

130-
it('Fetches and populates bandit parameters', async () => {
131-
await configurationRequestor.fetchAndStoreConfigurations();
134+
function responseMockGenerator(url: string) {
135+
const responseFile = url.includes('bandits')
136+
? MOCK_BANDIT_MODELS_RESPONSE_FILE
137+
: MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE;
138+
return readMockUFCResponse(responseFile);
139+
}
132140

133-
expect(fetchSpy).toHaveBeenCalledTimes(2); // Once for UFC, another for bandits
134-
135-
expect(flagStore.getKeys().length).toBeGreaterThanOrEqual(2);
136-
expect(flagStore.get('banner_bandit_flag')).toBeDefined();
137-
expect(flagStore.get('cold_start_bandit')).toBeDefined();
138-
139-
expect(banditModelStore.getKeys().length).toBeGreaterThanOrEqual(2);
140-
141-
const bannerBandit = banditModelStore.get('banner_bandit');
142-
expect(bannerBandit?.banditKey).toBe('banner_bandit');
143-
expect(bannerBandit?.modelName).toBe('falcon');
144-
expect(bannerBandit?.modelVersion).toBe('123');
145-
const bannerModelData = bannerBandit?.modelData;
146-
expect(bannerModelData?.gamma).toBe(1);
147-
expect(bannerModelData?.defaultActionScore).toBe(0);
148-
expect(bannerModelData?.actionProbabilityFloor).toBe(0);
149-
const bannerCoefficients = bannerModelData?.coefficients || {};
150-
expect(Object.keys(bannerCoefficients).length).toBe(2);
151-
152-
// Deep dive for the nike action
153-
const nikeCoefficients = bannerCoefficients['nike'];
154-
expect(nikeCoefficients.actionKey).toBe('nike');
155-
expect(nikeCoefficients.intercept).toBe(1);
156-
expect(nikeCoefficients.actionNumericCoefficients).toHaveLength(1);
157-
const nikeBrandAffinityCoefficient = nikeCoefficients.actionNumericCoefficients[0];
158-
expect(nikeBrandAffinityCoefficient.attributeKey).toBe('brand_affinity');
159-
expect(nikeBrandAffinityCoefficient.coefficient).toBe(1);
160-
expect(nikeBrandAffinityCoefficient.missingValueCoefficient).toBe(-0.1);
161-
expect(nikeCoefficients.actionCategoricalCoefficients).toHaveLength(2);
162-
const nikeLoyaltyTierCoefficient = nikeCoefficients.actionCategoricalCoefficients[0];
163-
expect(nikeLoyaltyTierCoefficient.attributeKey).toBe('loyalty_tier');
164-
expect(nikeLoyaltyTierCoefficient.missingValueCoefficient).toBe(0);
165-
expect(nikeLoyaltyTierCoefficient.valueCoefficients).toStrictEqual({
166-
gold: 4.5,
167-
silver: 3.2,
168-
bronze: 1.9,
141+
describe('Fetching bandits', () => {
142+
beforeAll(() => {
143+
initiateFetchSpy(responseMockGenerator);
169144
});
170-
expect(nikeCoefficients.subjectNumericCoefficients).toHaveLength(1);
171-
const nikeAccountAgeCoefficient = nikeCoefficients.subjectNumericCoefficients[0];
172-
expect(nikeAccountAgeCoefficient.attributeKey).toBe('account_age');
173-
expect(nikeAccountAgeCoefficient.coefficient).toBe(0.3);
174-
expect(nikeAccountAgeCoefficient.missingValueCoefficient).toBe(0);
175-
expect(nikeCoefficients.subjectCategoricalCoefficients).toHaveLength(1);
176-
const nikeGenderIdentityCoefficient = nikeCoefficients.subjectCategoricalCoefficients[0];
177-
expect(nikeGenderIdentityCoefficient.attributeKey).toBe('gender_identity');
178-
expect(nikeGenderIdentityCoefficient.missingValueCoefficient).toBe(2.3);
179-
expect(nikeGenderIdentityCoefficient.valueCoefficients).toStrictEqual({
180-
female: 0.5,
181-
male: -0.5,
145+
146+
it('Fetches and populates bandit parameters', async () => {
147+
await configurationRequestor.fetchAndStoreConfigurations();
148+
149+
expect(fetchSpy).toHaveBeenCalledTimes(2); // Once for UFC, another for bandits
150+
151+
expect(flagStore.getKeys().length).toBeGreaterThanOrEqual(2);
152+
expect(flagStore.get('banner_bandit_flag')).toBeDefined();
153+
expect(flagStore.get('cold_start_bandit')).toBeDefined();
154+
155+
expect(banditModelStore.getKeys().length).toBeGreaterThanOrEqual(2);
156+
157+
const bannerBandit = banditModelStore.get('banner_bandit');
158+
expect(bannerBandit?.banditKey).toBe('banner_bandit');
159+
expect(bannerBandit?.modelName).toBe('falcon');
160+
expect(bannerBandit?.modelVersion).toBe('123');
161+
const bannerModelData = bannerBandit?.modelData;
162+
expect(bannerModelData?.gamma).toBe(1);
163+
expect(bannerModelData?.defaultActionScore).toBe(0);
164+
expect(bannerModelData?.actionProbabilityFloor).toBe(0);
165+
const bannerCoefficients = bannerModelData?.coefficients || {};
166+
expect(Object.keys(bannerCoefficients).length).toBe(2);
167+
168+
// Deep dive for the nike action
169+
const nikeCoefficients = bannerCoefficients['nike'];
170+
expect(nikeCoefficients.actionKey).toBe('nike');
171+
expect(nikeCoefficients.intercept).toBe(1);
172+
expect(nikeCoefficients.actionNumericCoefficients).toHaveLength(1);
173+
const nikeBrandAffinityCoefficient = nikeCoefficients.actionNumericCoefficients[0];
174+
expect(nikeBrandAffinityCoefficient.attributeKey).toBe('brand_affinity');
175+
expect(nikeBrandAffinityCoefficient.coefficient).toBe(1);
176+
expect(nikeBrandAffinityCoefficient.missingValueCoefficient).toBe(-0.1);
177+
expect(nikeCoefficients.actionCategoricalCoefficients).toHaveLength(2);
178+
const nikeLoyaltyTierCoefficient = nikeCoefficients.actionCategoricalCoefficients[0];
179+
expect(nikeLoyaltyTierCoefficient.attributeKey).toBe('loyalty_tier');
180+
expect(nikeLoyaltyTierCoefficient.missingValueCoefficient).toBe(0);
181+
expect(nikeLoyaltyTierCoefficient.valueCoefficients).toStrictEqual({
182+
gold: 4.5,
183+
silver: 3.2,
184+
bronze: 1.9,
185+
});
186+
expect(nikeCoefficients.subjectNumericCoefficients).toHaveLength(1);
187+
const nikeAccountAgeCoefficient = nikeCoefficients.subjectNumericCoefficients[0];
188+
expect(nikeAccountAgeCoefficient.attributeKey).toBe('account_age');
189+
expect(nikeAccountAgeCoefficient.coefficient).toBe(0.3);
190+
expect(nikeAccountAgeCoefficient.missingValueCoefficient).toBe(0);
191+
expect(nikeCoefficients.subjectCategoricalCoefficients).toHaveLength(1);
192+
const nikeGenderIdentityCoefficient = nikeCoefficients.subjectCategoricalCoefficients[0];
193+
expect(nikeGenderIdentityCoefficient.attributeKey).toBe('gender_identity');
194+
expect(nikeGenderIdentityCoefficient.missingValueCoefficient).toBe(2.3);
195+
expect(nikeGenderIdentityCoefficient.valueCoefficients).toStrictEqual({
196+
female: 0.5,
197+
male: -0.5,
198+
});
199+
200+
// Just spot check the adidas parameters
201+
expect(bannerCoefficients['adidas'].subjectNumericCoefficients).toHaveLength(0);
202+
expect(
203+
bannerCoefficients['adidas'].subjectCategoricalCoefficients[0].valueCoefficients[
204+
'female'
205+
],
206+
).toBe(0);
207+
208+
const coldStartBandit = banditModelStore.get('cold_start_bandit');
209+
expect(coldStartBandit?.banditKey).toBe('cold_start_bandit');
210+
expect(coldStartBandit?.modelName).toBe('falcon');
211+
expect(coldStartBandit?.modelVersion).toBe('cold start');
212+
const coldStartModelData = coldStartBandit?.modelData;
213+
expect(coldStartModelData?.gamma).toBe(1);
214+
expect(coldStartModelData?.defaultActionScore).toBe(0);
215+
expect(coldStartModelData?.actionProbabilityFloor).toBe(0);
216+
expect(coldStartModelData?.coefficients).toStrictEqual({});
182217
});
183218

184-
// Just spot check the adidas parameters
185-
expect(bannerCoefficients['adidas'].subjectNumericCoefficients).toHaveLength(0);
186-
expect(
187-
bannerCoefficients['adidas'].subjectCategoricalCoefficients[0].valueCoefficients['female'],
188-
).toBe(0);
189-
190-
const coldStartBandit = banditModelStore.get('cold_start_bandit');
191-
expect(coldStartBandit?.banditKey).toBe('cold_start_bandit');
192-
expect(coldStartBandit?.modelName).toBe('falcon');
193-
expect(coldStartBandit?.modelVersion).toBe('cold start');
194-
const coldStartModelData = coldStartBandit?.modelData;
195-
expect(coldStartModelData?.gamma).toBe(1);
196-
expect(coldStartModelData?.defaultActionScore).toBe(0);
197-
expect(coldStartModelData?.actionProbabilityFloor).toBe(0);
198-
expect(coldStartModelData?.coefficients).toStrictEqual({});
199-
});
219+
it('Will not fetch bandit parameters if there is no store', async () => {
220+
configurationRequestor = new ConfigurationRequestor(httpClient, flagStore, null, null);
221+
await configurationRequestor.fetchAndStoreConfigurations();
222+
expect(fetchSpy).toHaveBeenCalledTimes(1);
223+
});
200224

201-
it('Will not fetch bandit parameters if there is no store', async () => {
202-
configurationRequestor = new ConfigurationRequestor(httpClient, flagStore, null, null);
203-
await configurationRequestor.fetchAndStoreConfigurations();
204-
expect(fetchSpy).toHaveBeenCalledTimes(1);
205-
});
225+
it('Should not fetch bandits if model version is un-changed', async () => {
226+
await configurationRequestor.fetchAndStoreConfigurations();
227+
expect(fetchSpy).toHaveBeenCalledTimes(2); // Once for UFC, another for bandits
206228

207-
it('Requests bandits only when model versions are different', async () => {
208-
await configurationRequestor.fetchAndStoreConfigurations();
209-
expect(fetchSpy).toHaveBeenCalledTimes(2); // Once for UFC, another for bandits
229+
await configurationRequestor.fetchAndStoreConfigurations();
230+
expect(fetchSpy).toHaveBeenCalledTimes(3); // Once just for UFC, bandits should be skipped
231+
});
210232

211-
await configurationRequestor.fetchAndStoreConfigurations();
212-
expect(fetchSpy).toHaveBeenCalledTimes(3); // Once just for UFC, bandits should be skipped
233+
it('Should fetch bandits if new bandit references model versions appeared', async () => {
234+
let updateUFC = false;
235+
await configurationRequestor.fetchAndStoreConfigurations();
236+
await configurationRequestor.fetchAndStoreConfigurations();
237+
expect(fetchSpy).toHaveBeenCalledTimes(3);
238+
239+
const customResponseMockGenerator = (url: string) => {
240+
const responseFile = url.includes('bandits')
241+
? MOCK_BANDIT_MODELS_RESPONSE_FILE
242+
: MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE;
243+
244+
const response = readMockUFCResponse(responseFile);
245+
246+
if (updateUFC === true) {
247+
// this if is needed to appease linter
248+
if (url.includes('config') && 'banditReferences' in response) {
249+
response.banditReferences.warm_start = {
250+
modelVersion: 'warm start',
251+
flagVariations: [
252+
{
253+
key: 'warm_start_bandit',
254+
flagKey: 'warm_start_bandit_flag',
255+
variationKey: 'warm_start_bandit',
256+
variationValue: 'warm_start_bandit',
257+
},
258+
],
259+
};
260+
}
261+
262+
if (url.includes('bandits') && 'bandits' in response) {
263+
response.bandits.warm_start = {
264+
banditKey: 'warm_start_bandit',
265+
modelName: 'pigeon',
266+
modelVersion: 'warm start',
267+
modelData: {
268+
gamma: 1.0,
269+
defaultActionScore: 0.0,
270+
actionProbabilityFloor: 0.0,
271+
coefficients: {},
272+
},
273+
};
274+
}
275+
}
276+
return response;
277+
};
278+
updateUFC = true;
279+
initiateFetchSpy(customResponseMockGenerator);
280+
281+
await configurationRequestor.fetchAndStoreConfigurations();
282+
expect(fetchSpy).toHaveBeenCalledTimes(2); // 2 because fetchSpy was re-initiated, 1UFC and 1bandits
283+
284+
// let's check if warm start was hydrated properly!
285+
const warm_start_bandit = banditModelStore.get('warm_start');
286+
expect(warm_start_bandit).toBeTruthy();
287+
expect(warm_start_bandit?.banditKey).toBe('warm_start_bandit');
288+
expect(warm_start_bandit?.modelVersion).toBe('warm start');
289+
expect(warm_start_bandit?.modelName).toBe('pigeon');
290+
expect(warm_start_bandit?.modelData.gamma).toBe(1);
291+
expect(warm_start_bandit?.modelData.defaultActionScore).toBe(0);
292+
expect(warm_start_bandit?.modelData.actionProbabilityFloor).toBe(0);
293+
expect(warm_start_bandit?.modelData.coefficients).toStrictEqual({});
294+
});
295+
296+
it('Should not fetch bandits if bandit references model versions shrunk', async () => {
297+
// Initial fetch
298+
await configurationRequestor.fetchAndStoreConfigurations();
299+
300+
// Let's mock UFC response so that cold_start is no longer retrieved
301+
const customResponseMockGenerator = (url: string) => {
302+
const responseFile = url.includes('bandits')
303+
? MOCK_BANDIT_MODELS_RESPONSE_FILE
304+
: MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE;
305+
306+
const response = readMockUFCResponse(responseFile);
307+
308+
if (url.includes('config') && 'banditReferences' in response) {
309+
delete response.banditReferences.cold_start_bandit;
310+
}
311+
return response;
312+
};
313+
314+
initiateFetchSpy(customResponseMockGenerator);
315+
await configurationRequestor.fetchAndStoreConfigurations();
316+
expect(fetchSpy).toHaveBeenCalledTimes(1); // only once for UFC
317+
318+
// cold start should still be in memory
319+
const warm_start_bandit = banditModelStore.get('cold_start_bandit');
320+
expect(warm_start_bandit).toBeTruthy();
321+
expect(warm_start_bandit?.banditKey).toBe('cold_start_bandit');
322+
expect(warm_start_bandit?.modelVersion).toBe('cold start');
323+
expect(warm_start_bandit?.modelName).toBe('falcon');
324+
expect(warm_start_bandit?.modelData.gamma).toBe(1);
325+
expect(warm_start_bandit?.modelData.defaultActionScore).toBe(0);
326+
expect(warm_start_bandit?.modelData.actionProbabilityFloor).toBe(0);
327+
expect(warm_start_bandit?.modelData.coefficients).toStrictEqual({});
328+
});
213329
});
214330
});
215331
});

0 commit comments

Comments
 (0)