|
15 | 15 | */ |
16 | 16 |
|
17 | 17 | import { vi, describe, it, expect, beforeEach } from 'vitest'; |
18 | | -import React from 'react'; |
19 | | -import { act } from '@testing-library/react'; |
| 18 | +import { act, waitFor } from '@testing-library/react'; |
20 | 19 | import { renderHook } from '@testing-library/react'; |
21 | | -import { OptimizelyContext, ProviderStateStore } from '../provider/index'; |
| 20 | +import { ProviderStateStore } from '../provider/index'; |
22 | 21 | import { useDecide } from './useDecide'; |
23 | | -import type { |
24 | | - OptimizelyUserContext, |
25 | | - OptimizelyDecision, |
26 | | - Client, |
27 | | - OptimizelyDecideOption, |
28 | | -} from '@optimizely/optimizely-sdk'; |
29 | | -import type { OptimizelyContextValue } from '../provider/index'; |
30 | | - |
31 | | -const MOCK_DECISION: OptimizelyDecision = { |
32 | | - variationKey: 'variation_1', |
33 | | - enabled: true, |
34 | | - variables: { color: 'red' }, |
35 | | - ruleKey: 'rule_1', |
36 | | - flagKey: 'flag_1', |
37 | | - userContext: {} as OptimizelyUserContext, |
38 | | - reasons: [], |
39 | | -}; |
40 | | - |
41 | | -function createMockUserContext(overrides?: Partial<Record<'decide', unknown>>): OptimizelyUserContext { |
42 | | - return { |
43 | | - getUserId: vi.fn().mockReturnValue('test-user'), |
44 | | - getAttributes: vi.fn().mockReturnValue({}), |
45 | | - fetchQualifiedSegments: vi.fn().mockResolvedValue(true), |
46 | | - decide: vi.fn().mockReturnValue(MOCK_DECISION), |
47 | | - decideAll: vi.fn(), |
48 | | - decideForKeys: vi.fn(), |
49 | | - setForcedDecision: vi.fn().mockReturnValue(true), |
50 | | - getForcedDecision: vi.fn(), |
51 | | - removeForcedDecision: vi.fn().mockReturnValue(true), |
52 | | - removeAllForcedDecisions: vi.fn().mockReturnValue(true), |
53 | | - trackEvent: vi.fn(), |
54 | | - getOptimizely: vi.fn(), |
55 | | - setQualifiedSegments: vi.fn(), |
56 | | - getQualifiedSegments: vi.fn().mockReturnValue([]), |
57 | | - qualifiedSegments: null, |
58 | | - ...overrides, |
59 | | - } as unknown as OptimizelyUserContext; |
60 | | -} |
61 | | - |
62 | | -function createMockClient(hasConfig = false): Client { |
63 | | - return { |
64 | | - getOptimizelyConfig: vi.fn().mockReturnValue(hasConfig ? { revision: '1' } : null), |
65 | | - createUserContext: vi.fn(), |
66 | | - onReady: vi.fn().mockResolvedValue({ success: true }), |
67 | | - notificationCenter: {}, |
68 | | - } as unknown as Client; |
69 | | -} |
70 | | - |
71 | | -function createWrapper(store: ProviderStateStore, client: Client) { |
72 | | - const contextValue: OptimizelyContextValue = { store, client }; |
73 | | - |
74 | | - return function Wrapper({ children }: { children: React.ReactNode }) { |
75 | | - return <OptimizelyContext.Provider value={contextValue}>{children}</OptimizelyContext.Provider>; |
76 | | - }; |
77 | | -} |
| 22 | +import { |
| 23 | + MOCK_DECISION, |
| 24 | + createMockUserContext, |
| 25 | + createMockClient, |
| 26 | + createProviderWrapper, |
| 27 | + createWrapper, |
| 28 | +} from './testUtils'; |
| 29 | +import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; |
78 | 30 |
|
79 | 31 | describe('useDecide', () => { |
80 | 32 | let store: ProviderStateStore; |
@@ -177,25 +129,6 @@ describe('useDecide', () => { |
177 | 129 | expect(result.current.decision).toBe(MOCK_DECISION); |
178 | 130 | }); |
179 | 131 |
|
180 | | - it('should re-evaluate when setClientReady fire', async () => { |
181 | | - const mockUserContext = createMockUserContext(); |
182 | | - store.setUserContext(mockUserContext); |
183 | | - // Client has no config yet |
184 | | - const wrapper = createWrapper(store, mockClient); |
185 | | - const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); |
186 | | - |
187 | | - expect(result.current.isLoading).toBe(true); |
188 | | - |
189 | | - // Simulate config becoming available when onReady resolves |
190 | | - (mockClient.getOptimizelyConfig as ReturnType<typeof vi.fn>).mockReturnValue({ revision: '1' }); |
191 | | - await act(async () => { |
192 | | - store.setClientReady(true); |
193 | | - }); |
194 | | - |
195 | | - expect(result.current.isLoading).toBe(false); |
196 | | - expect(result.current.decision).toBe(MOCK_DECISION); |
197 | | - }); |
198 | | - |
199 | 132 | it('should return error from store with isLoading: false', async () => { |
200 | 133 | const wrapper = createWrapper(store, mockClient); |
201 | 134 | const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); |
@@ -319,31 +252,37 @@ describe('useDecide', () => { |
319 | 252 | expect(result.current.decision).toBeNull(); |
320 | 253 | }); |
321 | 254 |
|
322 | | - it('should re-call decide() when setClientReady fires after sync decision was already served', async () => { |
323 | | - // Sync datafile scenario: config + userContext available before onReady |
324 | | - mockClient = createMockClient(true); |
| 255 | + it('should re-evaluate decision when OPTIMIZELY_CONFIG_UPDATE fires from the client', async () => { |
325 | 256 | const mockUserContext = createMockUserContext(); |
326 | | - store.setUserContext(mockUserContext); |
| 257 | + const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext); |
327 | 258 |
|
328 | | - const wrapper = createWrapper(store, mockClient); |
329 | 259 | const { result } = renderHook(() => useDecide('flag_1'), { wrapper }); |
330 | 260 |
|
331 | | - // Decision already served |
332 | | - expect(result.current.isLoading).toBe(false); |
| 261 | + // Wait for Provider's onReady + UserContextManager + queueMicrotask chain to complete |
| 262 | + await waitFor(() => { |
| 263 | + expect(result.current.isLoading).toBe(false); |
| 264 | + }); |
| 265 | + |
333 | 266 | expect(result.current.decision).toBe(MOCK_DECISION); |
334 | | - expect(mockUserContext.decide).toHaveBeenCalledTimes(1); |
335 | 267 |
|
336 | | - // onReady() resolves → setClientReady(true) fires → store state changes → |
337 | | - // useSyncExternalStore re-renders → useMemo recomputes → decide() called again. |
338 | | - // This is a redundant call since config + userContext haven't changed, |
339 | | - // but it's a one-time cost per flag per page load. |
| 268 | + const callCountBeforeUpdate = (mockUserContext.decide as ReturnType<typeof vi.fn>).mock.calls.length; |
| 269 | + |
| 270 | + // Simulate a new datafile with a different decision |
| 271 | + const updatedDecision: OptimizelyDecision = { |
| 272 | + ...MOCK_DECISION, |
| 273 | + variationKey: 'variation_2', |
| 274 | + variables: { color: 'blue' }, |
| 275 | + }; |
| 276 | + (mockUserContext.decide as ReturnType<typeof vi.fn>).mockReturnValue(updatedDecision); |
| 277 | + |
| 278 | + // Fire the config update notification (as the SDK would on datafile poll) |
340 | 279 | await act(async () => { |
341 | | - store.setClientReady(true); |
| 280 | + fireConfigUpdate(); |
342 | 281 | }); |
343 | 282 |
|
344 | | - expect(mockUserContext.decide).toHaveBeenCalledTimes(2); |
| 283 | + expect(mockUserContext.decide).toHaveBeenCalledTimes(callCountBeforeUpdate + 1); |
| 284 | + expect(result.current.decision).toBe(updatedDecision); |
345 | 285 | expect(result.current.isLoading).toBe(false); |
346 | | - expect(result.current.decision).toBe(MOCK_DECISION); |
347 | 286 | }); |
348 | 287 |
|
349 | 288 | describe('forced decision reactivity', () => { |
|
0 commit comments