Skip to content

Commit f30c3fa

Browse files
authored
feat: Evaluate custom targets in the feature flags runtime client (#1674)
1 parent ca384fe commit f30c3fa

6 files changed

Lines changed: 418 additions & 32 deletions

File tree

src/feature-flags/evaluator.spec.ts

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { Evaluator } from './evaluator';
22
import { InMemoryStore } from './in-memory-store';
3-
import { FlagPollEntry } from './interfaces';
3+
import {
4+
EvaluationContext,
5+
FlagPollEntry,
6+
RuntimeClientLogger,
7+
} from './interfaces';
48

59
describe('Evaluator', () => {
610
let store: InMemoryStore;
711
let evaluator: Evaluator;
12+
let logger: jest.Mocked<RuntimeClientLogger>;
813

914
const enabledFlag: FlagPollEntry = {
1015
slug: 'enabled-flag',
@@ -30,16 +35,40 @@ describe('Evaluator', () => {
3035
{ id: 'user_456', enabled: true },
3136
{ id: 'user_blocked', enabled: false },
3237
],
38+
custom_targets: [
39+
{ type: 'workspace', id: 'ws_123', enabled: true },
40+
{ type: 'workspace', id: 'ws_off', enabled: false },
41+
{ type: 'region', id: 'us-east-1', enabled: true },
42+
],
43+
},
44+
};
45+
46+
// Simulates a default-on flag with a disabled override, which the API
47+
// cannot produce yet: the row must not turn the flag off.
48+
const defaultOnFlag: FlagPollEntry = {
49+
slug: 'default-on-flag',
50+
enabled: true,
51+
default_value: true,
52+
targets: {
53+
users: [{ id: 'user_blocked', enabled: false }],
54+
organizations: [],
3355
},
3456
};
3557

3658
beforeEach(() => {
3759
store = new InMemoryStore();
38-
evaluator = new Evaluator(store);
60+
logger = {
61+
debug: jest.fn(),
62+
info: jest.fn(),
63+
warn: jest.fn(),
64+
error: jest.fn(),
65+
};
66+
evaluator = new Evaluator(store, logger);
3967
store.swap({
4068
'enabled-flag': enabledFlag,
4169
'disabled-flag': disabledFlag,
4270
'targeted-flag': targetedFlag,
71+
'default-on-flag': defaultOnFlag,
4372
});
4473
});
4574

@@ -53,31 +82,36 @@ describe('Evaluator', () => {
5382
expect(evaluator.isEnabled('disabled-flag')).toBe(false);
5483
});
5584

56-
it('returns target.enabled for matching organization', () => {
85+
it('returns true for a matching enabled organization target', () => {
5786
expect(
5887
evaluator.isEnabled('targeted-flag', { organizationId: 'org_123' }),
5988
).toBe(true);
6089
});
6190

62-
it('returns target.enabled for matching user', () => {
91+
it('returns true for a matching enabled user target', () => {
6392
expect(evaluator.isEnabled('targeted-flag', { userId: 'user_456' })).toBe(
6493
true,
6594
);
6695
});
6796

68-
it('returns false for user target with enabled=false', () => {
97+
it('treats targets with enabled=false as not present', () => {
98+
// No enabled match, so the flag falls back to its default value —
99+
// false here, but crucially the target does not force the flag off.
69100
expect(
70101
evaluator.isEnabled('targeted-flag', { userId: 'user_blocked' }),
71102
).toBe(false);
103+
expect(
104+
evaluator.isEnabled('default-on-flag', { userId: 'user_blocked' }),
105+
).toBe(true);
72106
});
73107

74-
it('prioritizes user target over organization target', () => {
108+
it('matches any enabled target with no precedence between types', () => {
75109
expect(
76110
evaluator.isEnabled('targeted-flag', {
77111
userId: 'user_blocked',
78112
organizationId: 'org_123',
79113
}),
80-
).toBe(false);
114+
).toBe(true);
81115
});
82116

83117
it('falls back to organization target when user target does not match', () => {
@@ -100,6 +134,109 @@ describe('Evaluator', () => {
100134
});
101135
});
102136

137+
describe('typed evaluation contexts', () => {
138+
it('matches custom targets by exact type and id', () => {
139+
expect(
140+
evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws_123' } }),
141+
).toBe(true);
142+
expect(
143+
evaluator.isEnabled('targeted-flag', { region: { id: 'us-east-1' } }),
144+
).toBe(true);
145+
146+
// A missing or mistyped target is a valid empty match, not an error.
147+
expect(
148+
evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws_456' } }),
149+
).toBe(false);
150+
expect(logger.warn).not.toHaveBeenCalled();
151+
});
152+
153+
it('accepts built-in types in the typed form', () => {
154+
expect(
155+
evaluator.isEnabled('targeted-flag', { user: { id: 'user_456' } }),
156+
).toBe(true);
157+
expect(
158+
evaluator.isEnabled('targeted-flag', {
159+
organization: { id: 'org_123' },
160+
}),
161+
).toBe(true);
162+
});
163+
164+
it('treats custom targets with enabled=false as not present', () => {
165+
expect(
166+
evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws_off' } }),
167+
).toBe(false);
168+
});
169+
170+
it('evaluates safely when the payload has no custom_targets field', () => {
171+
expect(
172+
evaluator.isEnabled('enabled-flag', { workspace: { id: 'ws_123' } }),
173+
).toBe(true);
174+
expect(
175+
evaluator.isEnabled('default-on-flag', {
176+
workspace: { id: 'ws_123' },
177+
}),
178+
).toBe(true);
179+
});
180+
181+
it('rejects a context mixing legacy and typed keys', () => {
182+
const hybridContext: EvaluationContext = {
183+
userId: 'user_456',
184+
workspace: { id: 'ws_123' },
185+
};
186+
187+
expect(evaluator.isEnabled('targeted-flag', hybridContext)).toBe(false);
188+
expect(logger.warn).toHaveBeenCalledTimes(1);
189+
});
190+
191+
it('does not treat unset keys as part of the context shape', () => {
192+
expect(
193+
evaluator.isEnabled('targeted-flag', {
194+
userId: undefined,
195+
workspace: { id: 'ws_123' },
196+
}),
197+
).toBe(true);
198+
expect(logger.warn).not.toHaveBeenCalled();
199+
});
200+
201+
it('ignores scalar extra fields on a legacy context', () => {
202+
const legacyWithExtras = {
203+
userId: 'user_456',
204+
requestId: 'req_1',
205+
} as EvaluationContext;
206+
207+
expect(evaluator.isEnabled('targeted-flag', legacyWithExtras)).toBe(true);
208+
expect(logger.warn).not.toHaveBeenCalled();
209+
});
210+
211+
it('ignores invalid target type keys with a warning', () => {
212+
expect(
213+
evaluator.isEnabled('targeted-flag', { Workspace: { id: 'ws_123' } }),
214+
).toBe(false);
215+
expect(logger.warn).toHaveBeenCalledTimes(1);
216+
});
217+
218+
it('ignores typed entries with invalid ids with a warning', () => {
219+
expect(
220+
evaluator.isEnabled('targeted-flag', { workspace: { id: '..' } }),
221+
).toBe(false);
222+
expect(
223+
evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws 123' } }),
224+
).toBe(false);
225+
expect(logger.warn).toHaveBeenCalledTimes(2);
226+
});
227+
228+
it('never throws on malformed context values', () => {
229+
const malformedContext = {
230+
workspace: 'ws_123',
231+
} as unknown as EvaluationContext;
232+
233+
expect(evaluator.isEnabled('targeted-flag', malformedContext)).toBe(
234+
false,
235+
);
236+
expect(logger.warn).toHaveBeenCalledTimes(1);
237+
});
238+
});
239+
103240
describe('getAllFlags', () => {
104241
it('evaluates all flags for the given context', () => {
105242
const result = evaluator.getAllFlags({ userId: 'user_456' });
@@ -108,16 +245,35 @@ describe('Evaluator', () => {
108245
'enabled-flag': true,
109246
'disabled-flag': false,
110247
'targeted-flag': true,
248+
'default-on-flag': true,
111249
});
112250
});
113251

252+
it('evaluates all flags for a typed context', () => {
253+
const result = evaluator.getAllFlags({ workspace: { id: 'ws_123' } });
254+
255+
expect(result).toEqual({
256+
'enabled-flag': true,
257+
'disabled-flag': false,
258+
'targeted-flag': true,
259+
'default-on-flag': true,
260+
});
261+
});
262+
263+
it('warns once per call for an invalid context, not once per flag', () => {
264+
evaluator.getAllFlags({ Workspace: { id: 'ws_123' } });
265+
266+
expect(logger.warn).toHaveBeenCalledTimes(1);
267+
});
268+
114269
it('works with empty context', () => {
115270
const result = evaluator.getAllFlags();
116271

117272
expect(result).toEqual({
118273
'enabled-flag': true,
119274
'disabled-flag': false,
120275
'targeted-flag': false,
276+
'default-on-flag': true,
121277
});
122278
});
123279
});

0 commit comments

Comments
 (0)