diff --git a/flagsmith-engine/environments/models.ts b/flagsmith-engine/environments/models.ts index 45d176e..efaff73 100644 --- a/flagsmith-engine/environments/models.ts +++ b/flagsmith-engine/environments/models.ts @@ -38,10 +38,12 @@ export class EnvironmentModel { project: ProjectModel; featureStates: FeatureStateModel[] = []; identityOverrides: IdentityModel[] = []; + name: string; - constructor(id: number, apiKey: string, project: ProjectModel) { + constructor(id: number, apiKey: string, project: ProjectModel, name: string) { this.id = id; this.apiKey = apiKey; this.project = project; + this.name = name; } } diff --git a/flagsmith-engine/environments/util.ts b/flagsmith-engine/environments/util.ts index c53e3bd..84cf3e6 100644 --- a/flagsmith-engine/environments/util.ts +++ b/flagsmith-engine/environments/util.ts @@ -11,7 +11,8 @@ export function buildEnvironmentModel(environmentJSON: any) { const environmentModel = new EnvironmentModel( environmentJSON.id, environmentJSON.api_key, - project + project, + environmentJSON.name ); environmentModel.featureStates = featureStates; if (!!environmentJSON.identity_overrides) { diff --git a/flagsmith-engine/evaluation/evaluationContext/mappers.ts b/flagsmith-engine/evaluation/evaluationContext/mappers.ts index 9ceb9ff..4616e81 100644 --- a/flagsmith-engine/evaluation/evaluationContext/mappers.ts +++ b/flagsmith-engine/evaluation/evaluationContext/mappers.ts @@ -43,7 +43,7 @@ function mapEnvironmentModelToEvaluationContext( ): GenericEvaluationContext { const environmentContext: EnvironmentContext = { key: environment.apiKey, - name: environment.project.name + name: environment.name }; const features: FeaturesWithMetadata = {}; diff --git a/tests/engine/unit/mappers.test.ts b/tests/engine/unit/mappers.test.ts new file mode 100644 index 0000000..b0d41ff --- /dev/null +++ b/tests/engine/unit/mappers.test.ts @@ -0,0 +1,356 @@ +import { test, expect, describe } from 'vitest'; +import { getEvaluationContext } from '../../../flagsmith-engine/evaluation/evaluationContext/mappers.js'; +import { buildEnvironmentModel } from '../../../flagsmith-engine/environments/util.js'; +import { EnvironmentModel } from '../../../flagsmith-engine/environments/models.js'; +import { IdentityModel } from '../../../flagsmith-engine/identities/models.js'; +import { TraitModel } from '../../../flagsmith-engine/identities/traits/models.js'; +import { FeatureModel, FeatureStateModel } from '../../../flagsmith-engine/features/models.js'; +import { + MultivariateFeatureOptionModel, + MultivariateFeatureStateValueModel +} from '../../../flagsmith-engine/features/models.js'; +import { CONSTANTS } from '../../../flagsmith-engine/features/constants.js'; +import { readFileSync } from 'fs'; +import { IDENTITY_OVERRIDE_SEGMENT_NAME } from '../../../flagsmith-engine/segments/constants.js'; +import { SegmentSource } from '../../../flagsmith-engine/evaluation/models.js'; + +const DATA_DIR = __dirname + '/../../sdk/data/'; + +describe('getEvaluationContext', () => { + const environmentJSON = JSON.parse(readFileSync(DATA_DIR + 'environment.json', 'utf-8')); + const testEnvironment = buildEnvironmentModel(environmentJSON); + + test('produces evaluation context from environment document', () => { + // When + const context = getEvaluationContext(testEnvironment); + + // Then - verify environment + expect(context).toBeDefined(); + expect(context.environment?.key).toBe('B62qaMZNwfiqT76p38ggrQ'); + expect(context.environment?.name).toBe('Test environment'); + expect(context.identity).toBeUndefined(); + + // Verify segments + expect(context.segments).toBeDefined(); + expect(context.segments).toHaveProperty('1'); + + const segment = context.segments!['1']; + expect(segment.key).toBe('1'); + expect(segment.name).toBe('regular_segment'); + expect(segment.rules.length).toBe(1); + expect(segment.overrides).toBeDefined(); + expect(Array.isArray(segment.overrides)).toBe(true); + expect(segment.metadata?.source).toBe(SegmentSource.API); + expect(segment.metadata?.id).toBe(1); + + // Verify segment rules + expect(segment.rules[0].type).toBe('ALL'); + expect(segment.rules[0].conditions).toEqual([]); + expect(segment.rules[0].rules?.length).toBe(1); + + const nestedRule = segment.rules[0].rules?.[0]!; + expect(nestedRule.type).toBe('ANY'); + expect(nestedRule.conditions?.length).toBe(1); + expect(nestedRule.rules?.length).toEqual(0); + + const condition = nestedRule.conditions?.[0]!; + expect(condition.property).toBe('age'); + expect(condition.operator).toBe('LESS_THAN'); + expect(condition.value).toBe('40'); + + // Verify identity override segment + const identityOverrideSegment = Object.values(context.segments!).find( + s => s.name === IDENTITY_OVERRIDE_SEGMENT_NAME + ); + expect(identityOverrideSegment).toBeDefined(); + expect(identityOverrideSegment!.name).toBe(IDENTITY_OVERRIDE_SEGMENT_NAME); + expect(identityOverrideSegment!.rules.length).toBe(1); + expect(identityOverrideSegment!.overrides?.length).toBe(1); + + const overrideRule = identityOverrideSegment!.rules?.[0]!; + expect(overrideRule.type).toBe('ALL'); + expect(overrideRule.conditions?.length).toBe(1); + + const overrideCondition = overrideRule.conditions?.[0]!; + expect(overrideCondition.property).toBe('$.identity.identifier'); + expect(overrideCondition.operator).toBe('IN'); + expect(overrideCondition.value).toContain('overridden-id'); + + const override = identityOverrideSegment!.overrides?.[0]!; + expect(override.name).toBe('some_feature'); + expect(override.enabled).toBe(false); + expect(override.value).toBe('some-overridden-value'); + expect(override.priority).toBe(-Infinity); + expect(override.metadata?.id).toBe(1); + + // Verify features + expect(context.features).toBeDefined(); + expect(context.features).toHaveProperty('some_feature'); + + const someFeature = context.features!['some_feature']; + expect(someFeature.name).toBe('some_feature'); + expect(someFeature.enabled).toBe(true); + expect(someFeature.value).toBe('some-value'); + expect(someFeature.priority).toBeUndefined(); + expect(someFeature.metadata?.id).toBe(1); + + // Verify multivariate feature + expect(context.features).toHaveProperty('mv_feature'); + const mvFeature = context.features!['mv_feature']; + expect(mvFeature.name).toBe('mv_feature'); + expect(mvFeature.enabled).toBe(false); + expect(mvFeature.value).toBe('foo'); + expect(mvFeature.priority).toBeUndefined(); + expect(mvFeature.variants?.length).toBe(1); + + const variant = mvFeature.variants![0]; + expect(variant.value).toBe('bar'); + expect(variant.weight).toBe(100); + expect(variant.priority).toBe(1); + }); + + test('maps multivariate features with multiple variants correctly', () => { + // Given + const mvOption1 = new MultivariateFeatureOptionModel('variant_a', 100); + const mvOption2 = new MultivariateFeatureOptionModel('variant_b', 200); + const mvOption3 = new MultivariateFeatureOptionModel('variant_c', 150); + + const mvValue1 = new MultivariateFeatureStateValueModel( + mvOption1, + 30, + 100, + '00000000-0000-0000-0000-000000000001' + ); + + const mvValue2 = new MultivariateFeatureStateValueModel( + mvOption2, + 50, + 200, + '00000000-0000-0000-0000-000000000002' + ); + + const mvValue3 = new MultivariateFeatureStateValueModel( + mvOption3, + 20, + 150, + '00000000-0000-0000-0000-000000000003' + ); + + const feature = new FeatureModel(999, 'multi_variant_feature', CONSTANTS.MULTIVARIATE); + const featureState = new FeatureStateModel(feature, true, 999); + featureState.setValue('control'); + featureState.multivariateFeatureStateValues = [mvValue1, mvValue2, mvValue3]; + + const envWithMv = new EnvironmentModel(1, 'test_key', testEnvironment.project, 'Test Env'); + envWithMv.featureStates = [featureState]; + + // When + const context = getEvaluationContext(envWithMv); + + // Then + const featureContext = context.features!['multi_variant_feature']; + expect(featureContext.variants?.length).toBe(3); + + expect(featureContext.variants![0].value).toBe('variant_a'); + expect(featureContext.variants![0].weight).toBe(30); + expect(featureContext.variants![0].priority).toBe(100); + + expect(featureContext.variants![1].value).toBe('variant_b'); + expect(featureContext.variants![1].weight).toBe(50); + expect(featureContext.variants![1].priority).toBe(200); + + expect(featureContext.variants![2].value).toBe('variant_c'); + expect(featureContext.variants![2].weight).toBe(20); + expect(featureContext.variants![2].priority).toBe(150); + }); + + test('handles multivariate features without IDs using UUID', () => { + // Given + const mvOption1 = new MultivariateFeatureOptionModel('option_x', undefined); + const mvOption2 = new MultivariateFeatureOptionModel('option_y', undefined); + + const mvValue1 = new MultivariateFeatureStateValueModel( + mvOption1, + 60, + undefined as any, + 'aaaaaaaa-bbbb-cccc-dddd-000000000001' + ); + + const mvValue2 = new MultivariateFeatureStateValueModel( + mvOption2, + 40, + undefined as any, + 'aaaaaaaa-bbbb-cccc-dddd-000000000002' + ); + + const feature = new FeatureModel(888, 'uuid_variant_feature', CONSTANTS.MULTIVARIATE); + const featureState = new FeatureStateModel(feature, true, 888); + featureState.setValue('default'); + featureState.multivariateFeatureStateValues = [mvValue1, mvValue2]; + + const envWithUuid = new EnvironmentModel( + 1, + 'test_key', + testEnvironment.project, + 'Test Env' + ); + envWithUuid.featureStates = [featureState]; + + // When + const context = getEvaluationContext(envWithUuid); + + // Then + const featureContext = context.features!['uuid_variant_feature']; + expect(featureContext.variants?.length).toBe(2); + + // When using UUID-based priorities, they become bigints + expect( + typeof featureContext.variants![0].priority === 'number' || + typeof featureContext.variants![0].priority === 'bigint' + ).toBe(true); + expect( + typeof featureContext.variants![1].priority === 'number' || + typeof featureContext.variants![1].priority === 'bigint' + ).toBe(true); + expect(featureContext.variants![0].priority).not.toBe(featureContext.variants![1].priority); + }); + + test('handles environment with no features', () => { + // Given - create a copy with no features + const emptyEnvJSON = { ...environmentJSON, feature_states: [] }; + const emptyEnv = buildEnvironmentModel(emptyEnvJSON); + + // When + const context = getEvaluationContext(emptyEnv); + + // Then + expect(context.features).toEqual({}); + expect(context.environment?.key).toBe('B62qaMZNwfiqT76p38ggrQ'); + expect(context.environment?.name).toBe('Test environment'); + }); + + test('produces evaluation context with identity', () => { + // Given + const identity = new IdentityModel( + '2024-01-01T00:00:00Z', + [new TraitModel('email', 'test@example.com'), new TraitModel('age', 25)], + [], + 'B62qaMZNwfiqT76p38ggrQ', + 'test_user', + undefined, + 123 + ); + + // When + const context = getEvaluationContext(testEnvironment, identity); + + // Then + expect(context.identity).toBeDefined(); + expect(context.identity?.identifier).toBe('test_user'); + expect(context.identity?.key).toBe('123'); + expect(context.identity?.traits).toEqual({ + email: 'test@example.com', + age: 25 + }); + }); + + test('produces evaluation context with override traits', () => { + // Given + const identity = new IdentityModel( + '2024-01-01T00:00:00Z', + [new TraitModel('email', 'original@example.com')], + [], + 'B62qaMZNwfiqT76p38ggrQ', + 'test_user', + undefined, + 456 + ); + + const overrideTraits = [ + new TraitModel('email', 'override@example.com'), + new TraitModel('premium', true) + ]; + + // When + const context = getEvaluationContext(testEnvironment, identity, overrideTraits); + + // Then + expect(context.identity?.traits).toEqual({ + email: 'override@example.com', + premium: true + }); + }); + + test('produces evaluation context without identity when isEnvironmentEvaluation is true', () => { + // Given + const identity = new IdentityModel( + '2024-01-01T00:00:00Z', + [new TraitModel('test', 'value')], + [], + 'B62qaMZNwfiqT76p38ggrQ', + 'test_user', + undefined, + 789 + ); + + // When + const context = getEvaluationContext(testEnvironment, identity, undefined, true); + + // Then + expect(context.identity).toBeUndefined(); + expect(context.environment).toBeDefined(); + expect(context.features).toBeDefined(); + expect(context.segments).toBeDefined(); + }); + + test('handles identity without django_id', () => { + // Given + const identity = new IdentityModel( + '2024-01-01T00:00:00Z', + [new TraitModel('name', 'John')], + [], + 'B62qaMZNwfiqT76p38ggrQ', + 'john_doe', + undefined, + undefined + ); + + // When + const context = getEvaluationContext(testEnvironment, identity); + + // Then + expect(context.identity?.identifier).toBe('john_doe'); + expect(context.identity?.key).toBeUndefined(); + expect(context.identity?.traits).toEqual({ name: 'John' }); + }); + + test('maps segment override priorities correctly', () => { + // When - using fixture which has segment with priority + const context = getEvaluationContext(testEnvironment); + + // Then - verify regular_segment has a feature override + const segment = context.segments!['1']; + expect(segment.overrides?.length).toBeGreaterThan(0); + + // The segment override from the fixture has no explicit priority, should be undefined + const segmentOverride = segment.overrides?.[0]!; + expect(segmentOverride.name).toBe('some_feature'); + expect(segmentOverride.priority).toBeUndefined(); + }); + + test('handles multiple identity overrides with same features', () => { + // Given - the fixture already has identity override with 'overridden-id' + // Verify it's mapped correctly + const context = getEvaluationContext(testEnvironment); + + // Then + const overrideSegments = Object.values(context.segments!).filter( + s => s.name === IDENTITY_OVERRIDE_SEGMENT_NAME + ); + + // The fixture has one identity override + expect(overrideSegments.length).toBe(1); + expect(overrideSegments[0].rules?.[0].conditions?.[0].value).toContain('overridden-id'); + expect(overrideSegments[0].overrides?.length).toBe(1); + }); +}); diff --git a/tests/sdk/data/environment.json b/tests/sdk/data/environment.json index 2dd3687..df92cd1 100644 --- a/tests/sdk/data/environment.json +++ b/tests/sdk/data/environment.json @@ -1,5 +1,6 @@ { "api_key": "B62qaMZNwfiqT76p38ggrQ", + "name": "Test environment", "project": { "name": "Test project", "organisation": {