Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / redux-utilities / asyncModelThunk / creator.test.ts
blob5e4a500d1faf966c75a217062b8267acb430989e
1 import { describe, expect, test } from '@jest/globals';
2 import { configureStore, createSlice } from '@reduxjs/toolkit';
4 import { createAsyncModelThunk, handleAsyncModel, previousSelector } from './creator';
5 import { getFetchedEphemeral } from './fetchedAt';
7 describe('creator', () => {
8     test('creates the action types', () => {
9         const thunkActionCreator = createAsyncModelThunk<
10             number,
11             {
12                 value: number;
13                 error: any;
14                 meta: { fetchedAt: 0; fetchedEphemeral: undefined };
15             },
16             any
17         >('myState/fetch', {
18             miss: async () => 42,
19             previous: previousSelector((state) => state),
20         });
22         expect(thunkActionCreator.fulfilled.type).toBe('myState/fetch/fulfilled');
23         expect(thunkActionCreator.pending.type).toBe('myState/fetch/pending');
24         expect(thunkActionCreator.rejected.type).toBe('myState/fetch/failed');
25     });
27     interface ModelState<T> {
28         value: T | undefined;
29         error: any;
30         meta: {
31             fetchedAt: number;
32             fetchedEphemeral: boolean | undefined;
33         };
34     }
36     interface StoreState<T> {
37         myState: ModelState<T>;
38     }
40     const stateKey = 'myState';
42     const selectState = <T>(state: StoreState<T>) => state[stateKey];
44     const setup = <T>(
45         miss: () => Promise<T>,
46         initialState: ModelState<T> = {
47             value: undefined,
48             error: undefined,
49             meta: {
50                 fetchedAt: 0,
51                 fetchedEphemeral: undefined,
52             },
53         }
54     ) => {
55         const fetchedEphemeral = getFetchedEphemeral();
56         const getFetchedAt = jest.fn(() => 0);
58         const thunkActionCreator = createAsyncModelThunk<T, StoreState<T>, undefined>('myState/fetch', {
59             miss,
60             previous: previousSelector(selectState),
61         });
63         const slice = createSlice({
64             name: 'myState',
65             initialState,
66             reducers: {},
67             extraReducers: (builder) => {
68                 handleAsyncModel(builder, thunkActionCreator, { getFetchedAt });
69             },
70         });
72         const actions: any[] = [];
74         const getNewStore = () =>
75             configureStore({
76                 reducer: {
77                     [stateKey]: slice.reducer,
78                 },
79                 middleware: (getDefaultMiddleware) => {
80                     return getDefaultMiddleware({}).prepend(() => (next) => (action) => {
81                         actions.push(action);
82                         return next(action);
83                     });
84                 },
85             });
87         return {
88             getNewStore,
89             actions,
90             thunkActionCreator,
91             getFetchedAt,
92             fetchedEphemeral,
93         };
94     };
96     test('successfully syncs to a value', async () => {
97         const value = 42;
99         const { fetchedEphemeral, getNewStore, actions, thunkActionCreator, getFetchedAt } = setup(async () => value);
101         const store = getNewStore();
103         expect(selectState(store.getState())).toEqual({
104             value: undefined,
105             error: undefined,
106             meta: { fetchedAt: 0, fetchedEphemeral: undefined },
107         });
108         getFetchedAt.mockImplementation(() => 1);
109         const promise = store.dispatch(thunkActionCreator.thunk());
110         expect(actions[1]).toEqual(thunkActionCreator.pending());
112         await expect(promise).resolves.toEqual(value);
114         expect(selectState(store.getState())).toEqual({
115             value: value,
116             error: undefined,
117             meta: { fetchedAt: 1, fetchedEphemeral },
118         });
119         expect(actions[2]).toEqual(thunkActionCreator.fulfilled(value));
120     });
122     test("successfully returns previous promise when it's pending", async () => {
123         const value = 42;
124         const fn = async () => value;
126         const { getNewStore, thunkActionCreator } = setup(fn);
128         const store = getNewStore();
129         const promise1 = store.dispatch(thunkActionCreator.thunk());
130         const promise2 = store.dispatch(thunkActionCreator.thunk());
131         expect(promise1).toBe(promise2);
132         await promise1;
133         const promise3 = store.dispatch(thunkActionCreator.thunk());
134         expect(promise1).not.toBe(promise3);
135     });
137     test("successfully returns previous value when it's been set", async () => {
138         const value = 42;
139         const fn = jest.fn(async () => value);
141         const { getNewStore, thunkActionCreator, getFetchedAt } = setup(fn);
143         const store = getNewStore();
144         getFetchedAt.mockImplementation(() => Date.now());
145         await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(value);
146         expect(fn).toHaveBeenCalledTimes(1);
147         await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(value);
148         expect(fn).toHaveBeenCalledTimes(1);
149     });
151     test("successfully re-fetches when it's expired", async () => {
152         const value = 42;
153         const fn = jest.fn(async () => value);
155         const { getNewStore, thunkActionCreator, getFetchedAt } = setup(fn);
157         const store = getNewStore();
158         getFetchedAt.mockImplementation(() => 1);
159         await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(value);
160         expect(fn).toHaveBeenCalledTimes(1);
161         getFetchedAt.mockImplementation(() => Date.now());
162         await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(value);
163         expect(fn).toHaveBeenCalledTimes(2);
164         await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(value);
165         expect(fn).toHaveBeenCalledTimes(2);
166     });
168     test('successfully rejects with an error', async () => {
169         const error = new Error('Something went wrong');
170         const { getNewStore, actions, thunkActionCreator, getFetchedAt } = setup(async () => {
171             throw error;
172         });
174         const store = getNewStore();
175         getFetchedAt.mockImplementation(() => 1);
177         expect(selectState(store.getState())).toEqual({
178             value: undefined,
179             error: undefined,
180             meta: { fetchedAt: 0, fetchedEphemeral: undefined },
181         });
182         const promise = store.dispatch(thunkActionCreator.thunk());
183         expect(actions[1]).toEqual(thunkActionCreator.pending());
185         await expect(promise).rejects.toThrow(error);
187         const serializedError = { message: error.message, name: error.name, stack: error.stack };
188         expect(selectState(store.getState())).toEqual({
189             value: undefined,
190             error: serializedError,
191             meta: { fetchedAt: 1, fetchedEphemeral: undefined },
192         });
193         expect(actions[2]).toEqual(thunkActionCreator.rejected(serializedError));
194     });
196     test('successfully retries after a failure', async () => {
197         const value = 42;
198         const error = new Error('Something went wrong');
199         let calls = 0;
200         const { fetchedEphemeral, getNewStore, actions, thunkActionCreator, getFetchedAt } = setup(async () => {
201             if (calls++ === 0) {
202                 throw error;
203             }
204             return value;
205         });
207         const store = getNewStore();
208         getFetchedAt.mockImplementation(() => 1);
210         expect(selectState(store.getState())).toEqual({
211             value: undefined,
212             error: undefined,
213             meta: { fetchedAt: 0, fetchedEphemeral: undefined },
214         });
215         const promise = store.dispatch(thunkActionCreator.thunk());
216         expect(actions[1]).toEqual(thunkActionCreator.pending());
218         await expect(promise).rejects.toThrow(error);
220         const serializedError = { message: error.message, name: error.name, stack: error.stack };
221         expect(selectState(store.getState())).toEqual({
222             value: undefined,
223             error: serializedError,
224             meta: { fetchedAt: 1, fetchedEphemeral: undefined },
225         });
226         expect(actions[2]).toEqual(thunkActionCreator.rejected(serializedError));
228         const successfulPromise = store.dispatch(thunkActionCreator.thunk());
229         getFetchedAt.mockImplementation(() => 2);
230         expect(selectState(store.getState())).toEqual({
231             value: undefined,
232             error: undefined,
233             meta: { fetchedAt: 1, fetchedEphemeral: undefined },
234         });
235         expect(actions[4]).toEqual(thunkActionCreator.pending());
237         await expect(successfulPromise).resolves.toEqual(value);
239         expect(selectState(store.getState())).toEqual({
240             value: value,
241             error: undefined,
242             meta: { fetchedAt: 2, fetchedEphemeral },
243         });
244         expect(actions[5]).toEqual(thunkActionCreator.fulfilled(value));
245     });
247     test('successfully re-fetches when fetchedEphemeral is unset', async () => {
248         const fn = jest.fn(async () => 43);
249         const { getNewStore, thunkActionCreator, getFetchedAt } = setup(fn, {
250             value: 42,
251             error: undefined,
252             meta: {
253                 fetchedAt: 2,
254                 fetchedEphemeral: undefined,
255             },
256         });
257         getFetchedAt.mockImplementation(() => 1);
258         const store = getNewStore();
260         await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(43);
261         expect(selectState(store.getState())).toEqual({
262             value: 43,
263             error: undefined,
264             meta: { fetchedAt: 1, fetchedEphemeral: true },
265         });
266     });
268     test('does not refetch when ephmeral is set and fetchedAt is good', async () => {
269         const fn = jest.fn(async () => 43);
270         const now = Date.now();
271         const { getNewStore, thunkActionCreator, getFetchedAt } = setup(fn, {
272             value: 42,
273             error: undefined,
274             meta: {
275                 fetchedAt: now,
276                 fetchedEphemeral: true,
277             },
278         });
279         getFetchedAt.mockImplementation(() => 1);
280         const store = getNewStore();
282         await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(42);
283         expect(selectState(store.getState())).toEqual({
284             value: 42,
285             error: undefined,
286             meta: { fetchedAt: now, fetchedEphemeral: true },
287         });
288     });