11import { Evaluator } from './evaluator' ;
22import { InMemoryStore } from './in-memory-store' ;
3- import { FlagPollEntry } from './interfaces' ;
3+ import {
4+ EvaluationContext ,
5+ FlagPollEntry ,
6+ RuntimeClientLogger ,
7+ } from './interfaces' ;
48
59describe ( '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