flagsmith-nodejs
Version:
Flagsmith lets you manage features flags and remote config across web, mobile and server side applications. Deliver true Continuous Integration. Get builds out faster. Control who has access to new features.
439 lines (409 loc) • 14.2 kB
text/typescript
import { ALL_RULE, CONDITION_OPERATORS } from '../../../../flagsmith-engine/segments/constants.js';
import {
traitsMatchSegmentCondition,
getContextValue,
getIdentitySegments
} from '../../../../flagsmith-engine/segments/evaluators.js';
import { TraitModel } from '../../../../flagsmith-engine/index.js';
import { getHashedPercentageForObjIds } from '../../../../flagsmith-engine/utils/hashing/index.js';
import {
EvaluationContext,
InSegmentCondition,
SegmentCondition,
SegmentCondition1
} from '../../../../flagsmith-engine/evaluation/models.js';
const isEsmBuild = process.env.ESM_BUILD === 'true';
// todo: work out how to implement this in a test function or before hook
vi.mock('../../../../flagsmith-engine/utils/hashing', () => ({
getHashedPercentageForObjIds: vi.fn(() => 1)
}));
let traitExistenceTestCases: [
string,
string | null | undefined,
string | null | undefined,
TraitModel[],
boolean
][] = [
[CONDITION_OPERATORS.IS_SET, 'foo', null, [], false],
[CONDITION_OPERATORS.IS_SET, 'foo', undefined, [new TraitModel('foo', 'bar')], true],
[
CONDITION_OPERATORS.IS_SET,
'foo',
undefined,
[new TraitModel('foo', 'bar'), new TraitModel('fooBaz', 'baz')],
true
],
[CONDITION_OPERATORS.IS_NOT_SET, 'foo', undefined, [], true],
[CONDITION_OPERATORS.IS_NOT_SET, 'foo', null, [new TraitModel('foo', 'bar')], false],
[
CONDITION_OPERATORS.IS_NOT_SET,
'foo',
null,
[new TraitModel('foo', 'bar'), new TraitModel('fooBaz', 'baz')],
false
]
];
test('test_traits_match_segment_condition_for_trait_existence_operators', () => {
for (const testCase of traitExistenceTestCases) {
const [operator, conditionProperty, conditionValue, traits, expectedResult] = testCase;
let segmentConditionModel = {
operator,
value: conditionValue,
property: conditionProperty
};
const traitsMap = traits.reduce((acc, trait) => {
acc[trait.traitKey] = trait.traitValue;
return acc;
}, {});
const context: EvaluationContext = {
environment: {
key: 'any',
name: 'any'
},
identity: {
traits: traitsMap,
key: 'any',
identifier: 'any'
}
};
expect(
traitsMatchSegmentCondition(segmentConditionModel as SegmentCondition, 'any', context)
).toBe(expectedResult);
}
});
describe('getIdentitySegments integration', () => {
test('returns only matching segments', () => {
const context: EvaluationContext = {
environment: { key: 'env', name: 'test' },
identity: {
key: 'user',
identifier: 'premium@example.com',
traits: { subscription: 'premium' }
},
segments: {
'1': {
key: '1',
name: 'premium_users',
rules: [
{
type: 'ALL',
conditions: [
{ property: 'subscription', operator: 'EQUAL', value: 'premium' }
]
}
],
overrides: []
},
'2': {
key: '2',
name: 'basic_users',
rules: [
{
type: 'ALL',
conditions: [
{ property: 'subscription', operator: 'EQUAL', value: 'basic' }
]
}
],
overrides: []
}
},
features: {}
};
const result = getIdentitySegments(context);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('premium_users');
});
test('returns empty array when no segments match', () => {
const context: EvaluationContext = {
environment: { key: 'env', name: 'test' },
identity: {
key: 'user',
identifier: 'test@example.com',
traits: { subscription: 'free' }
},
segments: {
'1': {
key: '1',
name: 'premium_users',
rules: [
{
type: 'ALL',
conditions: [
{ property: 'subscription', operator: 'EQUAL', value: 'premium' }
]
}
],
overrides: []
}
},
features: {}
};
const result = getIdentitySegments(context);
expect(result).toEqual([]);
});
});
describe('IN operator', () => {
const mockContext: EvaluationContext = {
environment: { key: 'env', name: 'test' },
identity: {
key: 'test-user',
identifier: 'test',
traits: { name: 'test' }
},
segments: {},
features: {}
};
test.each([
// Array of strings
[
{
property: '$.identity.identifier',
operator: CONDITION_OPERATORS.IN,
value: ['test', 'john-doe']
},
true
],
[
{
property: '$.identity.identifier',
operator: CONDITION_OPERATORS.IN,
value: ['john-doe']
},
false
],
// JSON encoded
[
{
property: '$.identity.identifier',
operator: CONDITION_OPERATORS.IN,
value: '["test", "john-doe"]'
},
true
],
[
{
property: '$.identity.identifier',
operator: CONDITION_OPERATORS.IN,
value: '["john-doe"]'
},
false
],
// Legacy value string to split
[
{
property: '$.identity.identifier',
operator: CONDITION_OPERATORS.IN,
value: 'test,john-doe'
},
true
],
[
{
property: '$.identity.identifier',
operator: CONDITION_OPERATORS.IN,
value: 'john-doe'
},
false
],
// Fails because the value is split in middle
[
{
property: '$.identity.identifier',
operator: CONDITION_OPERATORS.IN,
value: 'te,st,john-doe'
},
false
],
// Edge cases
[{ property: '$.identity.identifier', operator: CONDITION_OPERATORS.IN, value: '' }, false],
[{ property: '$.identity.identifier', operator: CONDITION_OPERATORS.IN, value: [] }, false],
[
{ property: '$.identity.identifier', operator: CONDITION_OPERATORS.IN, value: '[]' },
false
]
] as Array<[SegmentCondition | InSegmentCondition, boolean]>)(
'evaluates IN condition %j to %s',
(condition: SegmentCondition | InSegmentCondition, expected: boolean) => {
const result = traitsMatchSegmentCondition(condition, 'segment', mockContext);
expect(result).toBe(expected);
}
);
});
describe('getIdentitySegments single segment evaluation', () => {
const baseContext: EvaluationContext = {
environment: { key: 'env', name: 'test' },
identity: { key: 'user', identifier: 'test@example.com', traits: { age: 25 } },
segments: {},
features: {}
};
test('returns empty array for segment with no rules', () => {
const context = {
...baseContext,
segments: {
'1': {
key: '1',
name: 'empty_segment',
rules: [],
overrides: []
}
}
};
expect(getIdentitySegments(context)).toEqual([]);
});
test('returns segment when all rules match', () => {
const context: EvaluationContext = {
...baseContext,
segments: {
'1': {
key: '1',
name: 'matching_segment',
rules: [
{
type: ALL_RULE,
conditions: [
{
property: '$.identity.identifier',
operator: 'EQUAL',
value: 'test@example.com'
}
],
rules: []
},
{
type: ALL_RULE,
conditions: [
{
property: '$.identity.identifier',
operator: 'CONTAINS',
value: 'test@example.com'
}
],
rules: []
}
],
overrides: []
}
}
};
const result = getIdentitySegments(context);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('matching_segment');
});
test('returns empty array when any rule fails', () => {
const context: EvaluationContext = {
...baseContext,
segments: {
'1': {
key: '1',
name: 'failing_segment',
rules: [
{
type: ALL_RULE,
conditions: [
{
property: '$.identity.identifier',
operator: 'EQUAL',
value: 'test@example.com'
}
],
rules: []
},
{
type: ALL_RULE,
conditions: [{ property: 'age', operator: 'EQUAL', value: '30' }],
rules: []
}
],
overrides: []
}
}
};
expect(getIdentitySegments(context)).toEqual([]);
});
});
describe('getContextValue', () => {
const mockContext: EvaluationContext = {
environment: {
key: 'test-env-key',
name: 'Test Environment'
},
identity: {
key: 'user-123',
identifier: 'user@example.com'
},
segments: {},
features: {}
};
// Success cases
test.each([
['$.identity.identifier', 'user@example.com'],
['$.environment.name', 'Test Environment'],
['$.environment.key', 'test-env-key']
])('returns correct value for path %s', (jsonPath, expected) => {
const result = getContextValue(jsonPath, mockContext);
expect(result).toBe(expected);
});
// Undefined or invalid cases
test.each([
['$.identity.traits.user_type', 'unsupported nested path'],
['identity.identifier', 'missing $ prefix'],
['$.invalid.path', 'completely invalid path'],
['$.identity.nonexistent', 'valid structure but missing property'],
['', 'empty string'],
['$', 'just $ symbol']
])('returns undefined for %s (%s)', jsonPath => {
const result = getContextValue(jsonPath, mockContext);
expect(result).toBeUndefined();
});
// Context error cases
test.each([
[undefined, '$.identity.identifier', 'undefined context'],
[{ segments: {}, features: {} }, '$.identity.identifier', 'missing identity'],
[
{ identity: { key: 'test', identifier: 'test' }, segments: {}, features: {} },
'$.environment.name',
'missing environment'
]
])('returns undefined when %s', (context, jsonPath, _) => {
const result = getContextValue(jsonPath, context as EvaluationContext);
expect(result).toBeUndefined();
});
});
// Skip in ESM build: vi.mock doesn't work with external modules
describe.skipIf(isEsmBuild)('percentage split operator', () => {
const mockContext: EvaluationContext = {
environment: { key: 'env', name: 'Test Env' },
identity: {
key: 'user-123',
identifier: 'test@example.com',
traits: {
age: 25,
subscription: 'premium',
active: true
}
},
segments: {},
features: {}
};
beforeEach(() => {
vi.clearAllMocks();
});
test.each([
[25.5, 30, true],
[25.5, 20, false],
[25.5, 25.5, true],
[0, 0, true],
[100, 99.9, false]
])('percentage %d with threshold %d returns %s', (hashedValue, threshold, expected) => {
const mockHashFn = getHashedPercentageForObjIds;
mockHashFn.mockReturnValue(hashedValue);
const condition = {
operator: 'PERCENTAGE_SPLIT',
value: threshold.toString()
} as SegmentCondition1 | InSegmentCondition;
const result = traitsMatchSegmentCondition(condition, 'seg1', mockContext);
expect(result).toBe(expected);
expect(getHashedPercentageForObjIds).toHaveBeenCalledWith(['seg1', 'user-123']);
});
});