diff --git a/src/client/eppo-client-assignment-details.spec.ts b/src/client/eppo-client-assignment-details.spec.ts index b0eb09b..6dfecd3 100644 --- a/src/client/eppo-client-assignment-details.spec.ts +++ b/src/client/eppo-client-assignment-details.spec.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import { + AssignmentVariationValue, IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, readMockUFCResponse, @@ -328,8 +329,8 @@ describe('EppoClient get*AssignmentDetails', () => { flagKey: string, subjectKey: string, subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => IAssignmentDetails; + defaultValue: AssignmentVariationValue, + ) => IAssignmentDetails; if (!assignmentFn) { throw new Error(`Unknown variation type: ${variationType}`); } diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 3cb4fde..d4e8266 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -4,6 +4,7 @@ import * as td from 'testdouble'; import { ASSIGNMENT_TEST_DATA_DIR, + AssignmentVariationValue, getTestAssignments, IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, @@ -28,7 +29,11 @@ import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../i import { getMD5Hash } from '../obfuscation'; import { AttributeType } from '../types'; -import EppoClient, { checkTypeMatch, FlagConfigurationRequestParameters } from './eppo-client'; +import EppoClient, { + checkTypeMatch, + FlagConfigurationRequestParameters, + IAssignmentDetails, +} from './eppo-client'; import { initConfiguration } from './test-utils'; // Use a known salt to produce deterministic hashes @@ -317,50 +322,66 @@ describe('EppoClient E2E test', () => { }); }); - describe('UFC Shared Test Cases', () => { + describe.each(['Not Obfuscated', 'Obfuscated'])('UFC Shared Test Cases %s', (obfuscationType) => { const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); + const isObfuscated = obfuscationType === 'Obfuscated'; - describe('Not obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), - }); - }) as jest.Mock; + beforeAll(async () => { + global.fetch = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve( + readMockUFCResponse( + isObfuscated ? OBFUSCATED_MOCK_UFC_RESPONSE_FILE : MOCK_UFC_RESPONSE_FILE, + ), + ), + }); + }) as jest.Mock; - await initConfiguration(storage); - }); + await initConfiguration(storage); + }); - afterAll(() => { - jest.restoreAllMocks(); - }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe.each(['Scalar', 'With Details'])('%s', (assignmentType) => { + const assignmentWithDetails = assignmentType === 'With Details'; it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated }); client.setIsGracefulFailureMode(false); let assignments: { subject: SubjectTestCase; - assignment: string | boolean | number | null | object; + assignment: AssignmentVariationValue; }[] = []; - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; + const typeAssignmentFunctions = assignmentWithDetails + ? { + [VariationType.BOOLEAN]: client.getBooleanAssignmentDetails.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignmentDetails.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignmentDetails.bind(client), + [VariationType.STRING]: client.getStringAssignmentDetails.bind(client), + [VariationType.JSON]: client.getJSONAssignmentDetails.bind(client), + } + : { + [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; const assignmentFn = typeAssignmentFunctions[variationType] as ( flagKey: string, subjectKey: string, subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; + defaultValue: AssignmentVariationValue, + ) => AssignmentVariationValue | IAssignmentDetails; if (!assignmentFn) { throw new Error(`Unknown variation type: ${variationType}`); } @@ -370,56 +391,7 @@ describe('EppoClient E2E test', () => { assignmentFn, ); - validateTestAssignments(assignments, flag); - }); - }); - - describe('Obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE)), - }); - }) as jest.Mock; - - await initConfiguration(storage); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { - const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); - client.setIsGracefulFailureMode(false); - - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; - - const assignmentFn = typeAssignmentFunctions[variationType] as ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } - - const assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, - ); - - validateTestAssignments(assignments, flag); + validateTestAssignments(assignments, flag, assignmentWithDetails, isObfuscated); }); }); }); diff --git a/src/evaluator.ts b/src/evaluator.ts index 98ab3fd..ce7e381 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -144,7 +144,6 @@ export class Evaluator { configDetails.configFormat, ); } catch (err: any) { - console.error('>>>>', err); const flagEvaluationDetails = flagEvaluationDetailsBuilder.gracefulBuild( 'ASSIGNMENT_ERROR', `Assignment Error: ${err.message}`, diff --git a/src/http-client.ts b/src/http-client.ts index 063ecb3..7d13b81 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -43,6 +43,7 @@ export interface IUniversalFlagConfigResponse { } export interface IBanditParametersResponse { + updatedAt: string; // ISO formatted string bandits: Record; } diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 24b2c49..dadbc74 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -2,7 +2,13 @@ import * as fs from 'fs'; import { isEqual } from 'lodash'; -import { AttributeType, ContextAttributes, IAssignmentDetails, VariationType } from '../src'; +import { + AttributeType, + ContextAttributes, + IAssignmentDetails, + Variation, + VariationType, +} from '../src'; import { IFlagEvaluationDetails } from '../src/flag-evaluation-details-builder'; import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../src/http-client'; @@ -20,17 +26,19 @@ const MOCK_PRECOMPUTED_FILENAME = 'precomputed-v1'; export const MOCK_PRECOMPUTED_WIRE_FILE = `${MOCK_PRECOMPUTED_FILENAME}.json`; export const MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE = `${MOCK_PRECOMPUTED_FILENAME}-deobfuscated.json`; +export type AssignmentVariationValue = Variation['value'] | object; + export interface SubjectTestCase { subjectKey: string; subjectAttributes: Record; - assignment: string | number | boolean | object; + assignment: AssignmentVariationValue; evaluationDetails: IFlagEvaluationDetails; } export interface IAssignmentTestCase { flag: string; variationType: VariationType; - defaultValue: string | number | boolean | object; + defaultValue: AssignmentVariationValue; subjects: SubjectTestCase[]; } @@ -85,12 +93,15 @@ export function getTestAssignments( flagKey: string, subjectKey: string, subjectAttributes: Record, - defaultValue: string | number | boolean | object, - ) => never, -): { subject: SubjectTestCase; assignment: string | boolean | number | null | object }[] { + defaultValue: AssignmentVariationValue, + ) => AssignmentVariationValue | IAssignmentDetails, +): { + subject: SubjectTestCase; + assignment: AssignmentVariationValue | IAssignmentDetails; +}[] { const assignments: { subject: SubjectTestCase; - assignment: string | boolean | number | null | object; + assignment: AssignmentVariationValue; }[] = []; for (const subject of testCase.subjects) { const assignment = assignmentFn( @@ -104,48 +115,88 @@ export function getTestAssignments( return assignments; } -export function getTestAssignmentDetails( - testCase: IAssignmentTestCase, - assignmentDetailsFn: ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: string | number | boolean | object, - ) => never, -): { - subject: SubjectTestCase; - assignmentDetails: IAssignmentDetails; -}[] { - return testCase.subjects.map((subject) => ({ - subject, - assignmentDetails: assignmentDetailsFn( - testCase.flag, - subject.subjectKey, - subject.subjectAttributes, - testCase.defaultValue, - ), - })); -} +const configCreatedAt = ( + readMockUFCResponse(MOCK_UFC_RESPONSE_FILE) as IUniversalFlagConfigResponse +).createdAt; +const testHelperInstantiationDate = new Date(); export function validateTestAssignments( assignments: { subject: SubjectTestCase; - assignment: string | boolean | number | object | null; + assignment: AssignmentVariationValue | IAssignmentDetails; }[], flag: string, + withDetails: boolean, + isObfuscated: boolean, ) { for (const { subject, assignment } of assignments) { - if (!isEqual(assignment, subject.assignment)) { + let assignedVariation = assignment; + let assignmentDetails: IFlagEvaluationDetails | null = null; + if ( + withDetails === true && + typeof assignment === 'object' && + assignment !== null && + 'variation' in assignment + ) { + assignedVariation = assignment.variation; + assignmentDetails = assignment.evaluationDetails; + } + + if (!isEqual(assignedVariation, subject.assignment)) { // More friendly error message console.error( `subject ${subject.subjectKey} was assigned ${JSON.stringify( - assignment, + assignedVariation, undefined, 2, )} when expected ${JSON.stringify(subject.assignment, undefined, 2)} for flag ${flag}`, ); } - expect(assignment).toEqual(subject.assignment); + expect(assignedVariation).toEqual(subject.assignment); + + if (withDetails) { + if (!assignmentDetails) { + throw new Error('Expected assignmentDetails to be populated'); + } + expect(assignmentDetails.environmentName).toBe(subject.evaluationDetails.environmentName); + expect(assignmentDetails.flagEvaluationCode).toBe( + subject.evaluationDetails.flagEvaluationCode, + ); + expect(assignmentDetails.flagEvaluationDescription).toBe( + subject.evaluationDetails.flagEvaluationDescription, + ); + expect(assignmentDetails.variationKey).toBe(subject.evaluationDetails.variationKey); + // Use toString() to handle comparing JSON + expect(assignmentDetails.variationValue?.toString()).toBe( + subject.evaluationDetails.variationValue?.toString(), + ); + expect(assignmentDetails.configPublishedAt).toBe(configCreatedAt); + // cannot do an exact match for configFetchedAt because it will change based on fetch + expect(new Date(assignmentDetails.configFetchedAt).getTime()).toBeGreaterThan( + testHelperInstantiationDate.getTime(), + ); + + if (!isObfuscated) { + expect(assignmentDetails.matchedRule).toEqual(subject.evaluationDetails.matchedRule); + } else { + // When obfuscated, rules may be one-way hashed (e.g., for ONE_OF checks) so cannot be unobfuscated + // Thus we'll just check that the number of conditions is equal and relay on the unobfuscated + // tests for correctness + expect(assignmentDetails.matchedRule?.conditions || []).toHaveLength( + subject.evaluationDetails.matchedRule?.conditions.length || 0, + ); + } + + expect(assignmentDetails.matchedAllocation).toEqual( + subject.evaluationDetails.matchedAllocation, + ); + expect(assignmentDetails.unmatchedAllocations).toEqual( + subject.evaluationDetails.unmatchedAllocations, + ); + expect(assignmentDetails.unevaluatedAllocations).toEqual( + subject.evaluationDetails.unevaluatedAllocations, + ); + } } }