Skip to content

Commit b54e1e2

Browse files
[FSSDK-12296] decideAllAsync tests addition
1 parent bfe2941 commit b54e1e2

1 file changed

Lines changed: 365 additions & 0 deletions

File tree

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { vi, describe, it, expect, beforeEach } from 'vitest';
18+
import { act, waitFor } from '@testing-library/react';
19+
import { renderHook } from '@testing-library/react';
20+
import { ProviderStateStore } from '../provider/index';
21+
import { useDecideAllAsync } from './useDecideAllAsync';
22+
import {
23+
MOCK_DECISIONS,
24+
createMockUserContext,
25+
createMockClient,
26+
createProviderWrapper,
27+
createWrapper,
28+
} from './testUtils';
29+
import type { OptimizelyDecision, Client, OptimizelyDecideOption } from '@optimizely/optimizely-sdk';
30+
31+
describe('useDecideAllAsync', () => {
32+
let store: ProviderStateStore;
33+
let mockClient: Client;
34+
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
store = new ProviderStateStore();
38+
mockClient = createMockClient();
39+
});
40+
41+
// --- Store state tests ---
42+
43+
it('should throw when used outside of OptimizelyProvider', () => {
44+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
45+
46+
expect(() => {
47+
renderHook(() => useDecideAllAsync());
48+
}).toThrow('Optimizely hooks must be used within an <OptimizelyProvider>');
49+
50+
consoleSpy.mockRestore();
51+
});
52+
53+
it('should return isLoading: true when no config and no user context', () => {
54+
const wrapper = createWrapper(store, mockClient);
55+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
56+
57+
expect(result.current.isLoading).toBe(true);
58+
expect(result.current.error).toBeNull();
59+
expect(result.current.decisions).toEqual({});
60+
});
61+
62+
it('should return isLoading: true when config is available but no user context', () => {
63+
mockClient = createMockClient(true);
64+
const wrapper = createWrapper(store, mockClient);
65+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
66+
67+
expect(result.current.isLoading).toBe(true);
68+
expect(result.current.error).toBeNull();
69+
});
70+
71+
it('should return error from store with isLoading: false', async () => {
72+
const wrapper = createWrapper(store, mockClient);
73+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
74+
75+
expect(result.current.isLoading).toBe(true);
76+
77+
const testError = new Error('SDK initialization failed');
78+
await act(async () => {
79+
store.setError(testError);
80+
});
81+
82+
expect(result.current.isLoading).toBe(false);
83+
expect(result.current.error).toBe(testError);
84+
expect(result.current.decisions).toEqual({});
85+
});
86+
87+
it('should return isLoading: true while async call is in-flight', () => {
88+
mockClient = createMockClient(true);
89+
const mockUserContext = createMockUserContext();
90+
(mockUserContext.decideAllAsync as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
91+
store.setUserContext(mockUserContext);
92+
93+
const wrapper = createWrapper(store, mockClient);
94+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
95+
96+
expect(result.current.isLoading).toBe(true);
97+
expect(result.current.decisions).toEqual({});
98+
expect(result.current.error).toBeNull();
99+
});
100+
101+
it('should not trigger a redundant re-render when mounting with store already ready', () => {
102+
mockClient = createMockClient(true);
103+
const mockUserContext = createMockUserContext();
104+
(mockUserContext.decideAllAsync as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
105+
store.setUserContext(mockUserContext);
106+
107+
let renderCount = 0;
108+
const wrapper = createWrapper(store, mockClient);
109+
renderHook(
110+
() => {
111+
renderCount++;
112+
return useDecideAllAsync();
113+
},
114+
{ wrapper }
115+
);
116+
117+
expect(renderCount).toBe(1);
118+
});
119+
120+
it('should return decisions when async call resolves', async () => {
121+
mockClient = createMockClient(true);
122+
const mockUserContext = createMockUserContext();
123+
store.setUserContext(mockUserContext);
124+
125+
const wrapper = createWrapper(store, mockClient);
126+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
127+
128+
await waitFor(() => {
129+
expect(result.current.isLoading).toBe(false);
130+
});
131+
132+
expect(result.current.decisions).toEqual(MOCK_DECISIONS);
133+
expect(result.current.error).toBeNull();
134+
});
135+
136+
it('should return error when decideAllAsync() rejects', async () => {
137+
mockClient = createMockClient(true);
138+
const mockUserContext = createMockUserContext();
139+
const asyncError = new Error('CMAB request failed');
140+
(mockUserContext.decideAllAsync as ReturnType<typeof vi.fn>).mockRejectedValue(asyncError);
141+
store.setUserContext(mockUserContext);
142+
143+
const wrapper = createWrapper(store, mockClient);
144+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
145+
146+
await waitFor(() => {
147+
expect(result.current.isLoading).toBe(false);
148+
});
149+
150+
expect(result.current.error).toBe(asyncError);
151+
expect(result.current.decisions).toEqual({});
152+
});
153+
154+
it('should wrap non-Error rejection in Error object', async () => {
155+
mockClient = createMockClient(true);
156+
const mockUserContext = createMockUserContext();
157+
(mockUserContext.decideAllAsync as ReturnType<typeof vi.fn>).mockRejectedValue('string error');
158+
store.setUserContext(mockUserContext);
159+
160+
const wrapper = createWrapper(store, mockClient);
161+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
162+
163+
await waitFor(() => {
164+
expect(result.current.isLoading).toBe(false);
165+
});
166+
167+
expect(result.current.error).toBeInstanceOf(Error);
168+
expect(result.current.error!.message).toBe('string error');
169+
expect(result.current.decisions).toEqual({});
170+
});
171+
172+
// --- Race condition tests ---
173+
174+
it('should discard stale result when store state changes before resolve', async () => {
175+
mockClient = createMockClient(true);
176+
const mockUserContext1 = createMockUserContext();
177+
178+
let resolveFirst: (d: Record<string, OptimizelyDecision>) => void;
179+
const firstPromise = new Promise<Record<string, OptimizelyDecision>>((resolve) => {
180+
resolveFirst = resolve;
181+
});
182+
183+
(mockUserContext1.decideAllAsync as ReturnType<typeof vi.fn>).mockReturnValue(firstPromise);
184+
store.setUserContext(mockUserContext1);
185+
186+
const wrapper = createWrapper(store, mockClient);
187+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
188+
189+
expect(result.current.isLoading).toBe(true);
190+
191+
// New user context arrives — cancels first async call
192+
const updatedDecisions: Record<string, OptimizelyDecision> = {
193+
flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated' },
194+
};
195+
const mockUserContext2 = createMockUserContext();
196+
(mockUserContext2.decideAllAsync as ReturnType<typeof vi.fn>).mockResolvedValue(updatedDecisions);
197+
198+
await act(async () => {
199+
store.setUserContext(mockUserContext2);
200+
});
201+
202+
expect(result.current.isLoading).toBe(false);
203+
expect(result.current.decisions).toEqual(updatedDecisions);
204+
205+
// Resolve the stale first promise — should be ignored
206+
await act(async () => {
207+
resolveFirst!(MOCK_DECISIONS);
208+
});
209+
210+
expect(result.current.decisions).toEqual(updatedDecisions);
211+
});
212+
213+
// --- Re-evaluation tests ---
214+
215+
it('should re-fire async call when decideOptions change', async () => {
216+
mockClient = createMockClient(true);
217+
const mockUserContext = createMockUserContext();
218+
store.setUserContext(mockUserContext);
219+
220+
const wrapper = createWrapper(store, mockClient);
221+
const { result, rerender } = renderHook(({ options }) => useDecideAllAsync({ decideOptions: options }), {
222+
wrapper,
223+
initialProps: { options: undefined as OptimizelyDecideOption[] | undefined },
224+
});
225+
226+
await waitFor(() => {
227+
expect(result.current.isLoading).toBe(false);
228+
});
229+
230+
const newOptions = ['DISABLE_DECISION_EVENT'] as unknown as OptimizelyDecideOption[];
231+
(mockUserContext.decideAllAsync as ReturnType<typeof vi.fn>).mockClear();
232+
233+
rerender({ options: newOptions });
234+
235+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledWith(newOptions);
236+
});
237+
238+
it('should re-fire async call on config update', async () => {
239+
const mockUserContext = createMockUserContext();
240+
const { wrapper, fireConfigUpdate } = createProviderWrapper(mockUserContext);
241+
242+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
243+
244+
await waitFor(() => {
245+
expect(result.current.isLoading).toBe(false);
246+
});
247+
248+
expect(result.current.decisions).toEqual(MOCK_DECISIONS);
249+
250+
const callCountBeforeUpdate = (mockUserContext.decideAllAsync as ReturnType<typeof vi.fn>).mock.calls.length;
251+
252+
const updatedDecisions: Record<string, OptimizelyDecision> = {
253+
flag_1: { ...MOCK_DECISIONS['flag_1'], variationKey: 'updated_variation' },
254+
};
255+
(mockUserContext.decideAllAsync as ReturnType<typeof vi.fn>).mockResolvedValue(updatedDecisions);
256+
257+
await act(async () => {
258+
fireConfigUpdate();
259+
});
260+
261+
expect(result.current.decisions).toEqual(updatedDecisions);
262+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(callCountBeforeUpdate + 1);
263+
expect(result.current.isLoading).toBe(false);
264+
});
265+
266+
// --- Forced decision tests ---
267+
268+
describe('forced decision reactivity', () => {
269+
it('should re-fire on any setForcedDecision via subscribeAllForcedDecisions', async () => {
270+
mockClient = createMockClient(true);
271+
const mockUserContext = createMockUserContext();
272+
store.setUserContext(mockUserContext);
273+
274+
const wrapper = createWrapper(store, mockClient);
275+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
276+
277+
await waitFor(() => {
278+
expect(result.current.isLoading).toBe(false);
279+
});
280+
281+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(1);
282+
283+
await act(async () => {
284+
mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'forced_variation' });
285+
});
286+
287+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(2);
288+
289+
// A different flag also triggers re-evaluation
290+
await act(async () => {
291+
mockUserContext.setForcedDecision({ flagKey: 'flag_99' }, { variationKey: 'v99' });
292+
});
293+
294+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(3);
295+
});
296+
297+
it('should re-fire on removeForcedDecision', async () => {
298+
mockClient = createMockClient(true);
299+
const mockUserContext = createMockUserContext();
300+
store.setUserContext(mockUserContext);
301+
302+
const wrapper = createWrapper(store, mockClient);
303+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
304+
305+
await waitFor(() => {
306+
expect(result.current.isLoading).toBe(false);
307+
});
308+
309+
await act(async () => {
310+
mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' });
311+
});
312+
313+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(2);
314+
315+
await act(async () => {
316+
mockUserContext.removeForcedDecision({ flagKey: 'flag_1' });
317+
});
318+
319+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(3);
320+
});
321+
322+
it('should re-fire on removeAllForcedDecisions', async () => {
323+
mockClient = createMockClient(true);
324+
const mockUserContext = createMockUserContext();
325+
store.setUserContext(mockUserContext);
326+
327+
const wrapper = createWrapper(store, mockClient);
328+
const { result } = renderHook(() => useDecideAllAsync(), { wrapper });
329+
330+
await waitFor(() => {
331+
expect(result.current.isLoading).toBe(false);
332+
});
333+
334+
await act(async () => {
335+
mockUserContext.setForcedDecision({ flagKey: 'flag_1' }, { variationKey: 'v1' });
336+
});
337+
338+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(2);
339+
340+
await act(async () => {
341+
mockUserContext.removeAllForcedDecisions();
342+
});
343+
344+
expect(mockUserContext.decideAllAsync).toHaveBeenCalledTimes(3);
345+
});
346+
347+
it('should unsubscribe subscribeAllForcedDecisions listener on unmount', () => {
348+
mockClient = createMockClient(true);
349+
const mockUserContext = createMockUserContext();
350+
store.setUserContext(mockUserContext);
351+
352+
const unsubscribeAllFdSpy = vi.fn();
353+
const subscribeAllFdSpy = vi.spyOn(store, 'subscribeAllForcedDecisions').mockReturnValue(unsubscribeAllFdSpy);
354+
355+
const wrapper = createWrapper(store, mockClient);
356+
const { unmount } = renderHook(() => useDecideAllAsync(), { wrapper });
357+
358+
expect(subscribeAllFdSpy).toHaveBeenCalledTimes(1);
359+
360+
unmount();
361+
362+
expect(unsubscribeAllFdSpy).toHaveBeenCalledTimes(1);
363+
});
364+
});
365+
});

0 commit comments

Comments
 (0)