Skip to content

Commit 13928f4

Browse files
[FSSDK-12249] provider state + test adjustment for forced decision
1 parent c0dd706 commit 13928f4

3 files changed

Lines changed: 368 additions & 7 deletions

File tree

src/provider/OptimizelyProvider.spec.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ describe('OptimizelyProvider', () => {
354354
getUserId: vi.fn().mockReturnValue('user-1'),
355355
qualifiedSegments: null as string[] | null,
356356
fetchQualifiedSegments: vi.fn().mockResolvedValue(true),
357+
setForcedDecision: vi.fn(),
358+
removeForcedDecision: vi.fn(),
359+
removeAllForcedDecisions: vi.fn(),
357360
} as unknown as OptimizelyUserContext;
358361

359362
const mockClient = createMockClient({
@@ -439,6 +442,9 @@ describe('OptimizelyProvider', () => {
439442
getUserId: vi.fn().mockReturnValue('user-1'),
440443
qualifiedSegments: null as string[] | null,
441444
fetchQualifiedSegments: vi.fn().mockResolvedValue(true),
445+
setForcedDecision: vi.fn(),
446+
removeForcedDecision: vi.fn(),
447+
removeAllForcedDecisions: vi.fn(),
442448
} as unknown as OptimizelyUserContext;
443449

444450
const mockClient = createMockClient({

src/provider/ProviderStateStore.spec.ts

Lines changed: 259 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ import { ProviderStateStore } from './ProviderStateStore';
2020
describe('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

Comments
 (0)