@@ -20,6 +20,23 @@ import { ProviderStateStore } from './ProviderStateStore';
2020describe ( 'ProviderStateStore' , ( ) => {
2121 let store : ProviderStateStore ;
2222
23+ /**
24+ * Creates a minimal mock OptimizelyUserContext with forced decision stubs.
25+ * This is needed because setUserContext wraps forced decision methods.
26+ */
27+ function createMockUserContext ( overrides ?: {
28+ setForcedDecision ?: ( ...args : any [ ] ) => boolean ;
29+ removeForcedDecision ?: ( ...args : any [ ] ) => boolean ;
30+ removeAllForcedDecisions ?: ( ) => boolean ;
31+ } ) {
32+ return {
33+ userId : 'test-user' ,
34+ setForcedDecision : overrides ?. setForcedDecision ?? vi . fn ( ) . mockReturnValue ( true ) ,
35+ removeForcedDecision : overrides ?. removeForcedDecision ?? vi . fn ( ) . mockReturnValue ( true ) ,
36+ removeAllForcedDecisions : overrides ?. removeAllForcedDecisions ?? vi . fn ( ) . mockReturnValue ( true ) ,
37+ } as any ;
38+ }
39+
2340 beforeEach ( ( ) => {
2441 store = new ProviderStateStore ( ) ;
2542 } ) ;
@@ -122,7 +139,7 @@ describe('ProviderStateStore', () => {
122139 } ) ;
123140
124141 it ( 'should preserve other state properties' , ( ) => {
125- const mockUserContext = { userId : 'test-user' } as any ;
142+ const mockUserContext = createMockUserContext ( ) ;
126143 const mockError = new Error ( 'test' ) ;
127144
128145 store . setUserContext ( mockUserContext ) ;
@@ -138,15 +155,15 @@ describe('ProviderStateStore', () => {
138155
139156 describe ( 'setUserContext' , ( ) => {
140157 it ( 'should update userContext state' , ( ) => {
141- const mockUserContext = { userId : 'test-user' } as any ;
158+ const mockUserContext = createMockUserContext ( ) ;
142159
143160 store . setUserContext ( mockUserContext ) ;
144161
145162 expect ( store . getState ( ) . userContext ) . toBe ( mockUserContext ) ;
146163 } ) ;
147164
148165 it ( 'should allow setting userContext to null' , ( ) => {
149- const mockUserContext = { userId : 'test-user' } as any ;
166+ const mockUserContext = createMockUserContext ( ) ;
150167 store . setUserContext ( mockUserContext ) ;
151168
152169 store . setUserContext ( null ) ;
@@ -159,7 +176,7 @@ describe('ProviderStateStore', () => {
159176 store . setClientReady ( true ) ;
160177 store . setError ( mockError ) ;
161178
162- const mockUserContext = { userId : 'test-user' } as any ;
179+ const mockUserContext = createMockUserContext ( ) ;
163180 store . setUserContext ( mockUserContext ) ;
164181
165182 const state = store . getState ( ) ;
@@ -199,7 +216,7 @@ describe('ProviderStateStore', () => {
199216 } ) ;
200217
201218 it ( 'should not clear other state when error is set' , ( ) => {
202- const mockUserContext = { userId : 'test-user' } as any ;
219+ const mockUserContext = createMockUserContext ( ) ;
203220 store . setClientReady ( true ) ;
204221 store . setUserContext ( mockUserContext ) ;
205222
@@ -216,7 +233,7 @@ describe('ProviderStateStore', () => {
216233 const listener = vi . fn ( ) ;
217234 store . subscribe ( listener ) ;
218235
219- const mockUserContext = { userId : 'test-user' } as any ;
236+ const mockUserContext = createMockUserContext ( ) ;
220237 store . setState ( {
221238 isClientReady : true ,
222239 userContext : mockUserContext ,
@@ -243,7 +260,7 @@ describe('ProviderStateStore', () => {
243260
244261 describe ( 'reset' , ( ) => {
245262 it ( 'should reset to initial state' , ( ) => {
246- const mockUserContext = { userId : 'test-user' } as any ;
263+ const mockUserContext = createMockUserContext ( ) ;
247264 store . setClientReady ( true ) ;
248265 store . setUserContext ( mockUserContext ) ;
249266 store . setError ( new Error ( 'test' ) ) ;
@@ -266,4 +283,239 @@ describe('ProviderStateStore', () => {
266283 expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ;
267284 } ) ;
268285 } ) ;
286+
287+ describe ( 'forced decision reactivity' , ( ) => {
288+ it ( 'setUserContext wraps forced decision methods' , ( ) => {
289+ const ctx = createMockUserContext ( ) ;
290+ const listener = vi . fn ( ) ;
291+
292+ store . subscribeForcedDecision ( 'flag-a' , listener ) ;
293+ store . setUserContext ( ctx ) ;
294+
295+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
296+
297+ expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ;
298+ } ) ;
299+
300+ it ( 'subscribeForcedDecision delivers per-flagKey notifications' , ( ) => {
301+ const ctx = createMockUserContext ( ) ;
302+ const listenerA = vi . fn ( ) ;
303+ const listenerB = vi . fn ( ) ;
304+
305+ store . subscribeForcedDecision ( 'flag-a' , listenerA ) ;
306+ store . subscribeForcedDecision ( 'flag-b' , listenerB ) ;
307+ store . setUserContext ( ctx ) ;
308+
309+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
310+
311+ expect ( listenerA ) . toHaveBeenCalledTimes ( 1 ) ;
312+ expect ( listenerB ) . not . toHaveBeenCalled ( ) ;
313+ } ) ;
314+
315+ it ( 'removeForcedDecision notifies per-flagKey' , ( ) => {
316+ const ctx = createMockUserContext ( ) ;
317+ const listenerA = vi . fn ( ) ;
318+ const listenerB = vi . fn ( ) ;
319+
320+ store . subscribeForcedDecision ( 'flag-a' , listenerA ) ;
321+ store . subscribeForcedDecision ( 'flag-b' , listenerB ) ;
322+ store . setUserContext ( ctx ) ;
323+
324+ // First set, then remove
325+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
326+ listenerA . mockClear ( ) ;
327+
328+ ctx . removeForcedDecision ( { flagKey : 'flag-a' } ) ;
329+
330+ expect ( listenerA ) . toHaveBeenCalledTimes ( 1 ) ;
331+ expect ( listenerB ) . not . toHaveBeenCalled ( ) ;
332+ } ) ;
333+
334+ it ( 'removeAllForcedDecisions notifies all tracked flagKeys' , ( ) => {
335+ const ctx = createMockUserContext ( ) ;
336+ const listenerA = vi . fn ( ) ;
337+ const listenerB = vi . fn ( ) ;
338+ const listenerC = vi . fn ( ) ;
339+
340+ store . subscribeForcedDecision ( 'flag-a' , listenerA ) ;
341+ store . subscribeForcedDecision ( 'flag-b' , listenerB ) ;
342+ store . subscribeForcedDecision ( 'flag-c' , listenerC ) ;
343+ store . setUserContext ( ctx ) ;
344+
345+ // Set forced decisions for flag-a and flag-b, but NOT flag-c
346+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
347+ ctx . setForcedDecision ( { flagKey : 'flag-b' } , { variationKey : 'v2' } ) ;
348+ listenerA . mockClear ( ) ;
349+ listenerB . mockClear ( ) ;
350+ listenerC . mockClear ( ) ;
351+
352+ ctx . removeAllForcedDecisions ( ) ;
353+
354+ // flag-a and flag-b were tracked, so their listeners fire
355+ expect ( listenerA ) . toHaveBeenCalledTimes ( 1 ) ;
356+ expect ( listenerB ) . toHaveBeenCalledTimes ( 1 ) ;
357+ // flag-c was never set, so its listener should NOT fire
358+ expect ( listenerC ) . not . toHaveBeenCalled ( ) ;
359+ } ) ;
360+
361+ it ( 'failed forced decision does NOT notify' , ( ) => {
362+ const ctx = createMockUserContext ( {
363+ setForcedDecision : vi . fn ( ) . mockReturnValue ( false ) ,
364+ removeForcedDecision : vi . fn ( ) . mockReturnValue ( false ) ,
365+ removeAllForcedDecisions : vi . fn ( ) . mockReturnValue ( false ) ,
366+ } ) ;
367+ const listener = vi . fn ( ) ;
368+
369+ store . subscribeForcedDecision ( 'flag-a' , listener ) ;
370+ store . setUserContext ( ctx ) ;
371+
372+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
373+ ctx . removeForcedDecision ( { flagKey : 'flag-a' } ) ;
374+ ctx . removeAllForcedDecisions ( ) ;
375+
376+ expect ( listener ) . not . toHaveBeenCalled ( ) ;
377+ } ) ;
378+
379+ it ( 'null context is not wrapped and does not throw' , ( ) => {
380+ expect ( ( ) => {
381+ store . setUserContext ( null ) ;
382+ } ) . not . toThrow ( ) ;
383+
384+ expect ( store . getState ( ) . userContext ) . toBeNull ( ) ;
385+ } ) ;
386+
387+ it ( 'new context replaces old wrapping — stale context does not notify' , ( ) => {
388+ const ctxA = createMockUserContext ( ) ;
389+ const ctxB = createMockUserContext ( ) ;
390+ const listener = vi . fn ( ) ;
391+
392+ store . subscribeForcedDecision ( 'flag-a' , listener ) ;
393+
394+ // Set ctxA, then replace with ctxB
395+ store . setUserContext ( ctxA ) ;
396+ store . setUserContext ( ctxB ) ;
397+ listener . mockClear ( ) ;
398+
399+ // Calling setForcedDecision on the OLD context (ctxA)
400+ // should NOT notify — staleness guard
401+ ctxA . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
402+
403+ expect ( listener ) . not . toHaveBeenCalled ( ) ;
404+
405+ // Calling on the CURRENT context (ctxB) should notify
406+ ctxB . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v2' } ) ;
407+
408+ expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ;
409+ } ) ;
410+
411+ it ( 'stale context removeForcedDecision does not notify' , ( ) => {
412+ const ctxA = createMockUserContext ( ) ;
413+ const ctxB = createMockUserContext ( ) ;
414+ const listener = vi . fn ( ) ;
415+
416+ store . subscribeForcedDecision ( 'flag-a' , listener ) ;
417+
418+ store . setUserContext ( ctxA ) ;
419+ ctxA . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
420+ listener . mockClear ( ) ;
421+
422+ // Replace with ctxB
423+ store . setUserContext ( ctxB ) ;
424+ listener . mockClear ( ) ;
425+
426+ // Old context remove — should not notify
427+ ctxA . removeForcedDecision ( { flagKey : 'flag-a' } ) ;
428+ expect ( listener ) . not . toHaveBeenCalled ( ) ;
429+ } ) ;
430+
431+ it ( 'stale context removeAllForcedDecisions does not notify' , ( ) => {
432+ const ctxA = createMockUserContext ( ) ;
433+ const ctxB = createMockUserContext ( ) ;
434+ const listener = vi . fn ( ) ;
435+
436+ store . subscribeForcedDecision ( 'flag-a' , listener ) ;
437+
438+ store . setUserContext ( ctxA ) ;
439+ ctxA . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
440+ listener . mockClear ( ) ;
441+
442+ // Replace with ctxB
443+ store . setUserContext ( ctxB ) ;
444+ listener . mockClear ( ) ;
445+
446+ // Old context removeAll — should not notify
447+ ctxA . removeAllForcedDecisions ( ) ;
448+ expect ( listener ) . not . toHaveBeenCalled ( ) ;
449+ } ) ;
450+
451+ it ( 'unsubscribe removes the listener' , ( ) => {
452+ const ctx = createMockUserContext ( ) ;
453+ const listener = vi . fn ( ) ;
454+
455+ const unsubscribe = store . subscribeForcedDecision ( 'flag-a' , listener ) ;
456+ store . setUserContext ( ctx ) ;
457+
458+ // First call — should notify
459+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
460+ expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ;
461+
462+ // Unsubscribe
463+ unsubscribe ( ) ;
464+ listener . mockClear ( ) ;
465+
466+ // Second call — should NOT notify
467+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v2' } ) ;
468+ expect ( listener ) . not . toHaveBeenCalled ( ) ;
469+ } ) ;
470+
471+ it ( 'reset clears forced decision listeners' , ( ) => {
472+ const ctx = createMockUserContext ( ) ;
473+ const listener = vi . fn ( ) ;
474+
475+ store . subscribeForcedDecision ( 'flag-a' , listener ) ;
476+ store . setUserContext ( ctx ) ;
477+
478+ ctx . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v1' } ) ;
479+ expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ;
480+ listener . mockClear ( ) ;
481+
482+ // Reset the store
483+ store . reset ( ) ;
484+
485+ // Set a new context and trigger forced decision
486+ const ctxNew = createMockUserContext ( ) ;
487+ store . setUserContext ( ctxNew ) ;
488+
489+ ctxNew . setForcedDecision ( { flagKey : 'flag-a' } , { variationKey : 'v2' } ) ;
490+
491+ // Old listener should not be called — it was cleared by reset
492+ expect ( listener ) . not . toHaveBeenCalled ( ) ;
493+ } ) ;
494+
495+ it ( 'original methods are still called on the underlying context' , ( ) => {
496+ const originalSet = vi . fn ( ) . mockReturnValue ( true ) ;
497+ const originalRemove = vi . fn ( ) . mockReturnValue ( true ) ;
498+ const originalRemoveAll = vi . fn ( ) . mockReturnValue ( true ) ;
499+
500+ const ctx = createMockUserContext ( {
501+ setForcedDecision : originalSet ,
502+ removeForcedDecision : originalRemove ,
503+ removeAllForcedDecisions : originalRemoveAll ,
504+ } ) ;
505+
506+ store . setUserContext ( ctx ) ;
507+
508+ const decisionContext = { flagKey : 'flag-a' } ;
509+ const decision = { variationKey : 'v1' } ;
510+
511+ ctx . setForcedDecision ( decisionContext , decision ) ;
512+ expect ( originalSet ) . toHaveBeenCalledWith ( decisionContext , decision ) ;
513+
514+ ctx . removeForcedDecision ( decisionContext ) ;
515+ expect ( originalRemove ) . toHaveBeenCalledWith ( decisionContext ) ;
516+
517+ ctx . removeAllForcedDecisions ( ) ;
518+ expect ( originalRemoveAll ) . toHaveBeenCalled ( ) ;
519+ } ) ;
520+ } ) ;
269521} ) ;
0 commit comments