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<
14 meta: { fetchedAt: 0; fetchedEphemeral: undefined };
19 previous: previousSelector((state) => state),
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');
27 interface ModelState<T> {
32 fetchedEphemeral: boolean | undefined;
36 interface StoreState<T> {
37 myState: ModelState<T>;
40 const stateKey = 'myState';
42 const selectState = <T>(state: StoreState<T>) => state[stateKey];
45 miss: () => Promise<T>,
46 initialState: ModelState<T> = {
51 fetchedEphemeral: undefined,
55 const fetchedEphemeral = getFetchedEphemeral();
56 const getFetchedAt = jest.fn(() => 0);
58 const thunkActionCreator = createAsyncModelThunk<T, StoreState<T>, undefined>('myState/fetch', {
60 previous: previousSelector(selectState),
63 const slice = createSlice({
67 extraReducers: (builder) => {
68 handleAsyncModel(builder, thunkActionCreator, { getFetchedAt });
72 const actions: any[] = [];
74 const getNewStore = () =>
77 [stateKey]: slice.reducer,
79 middleware: (getDefaultMiddleware) => {
80 return getDefaultMiddleware({}).prepend(() => (next) => (action) => {
96 test('successfully syncs to a value', async () => {
99 const { fetchedEphemeral, getNewStore, actions, thunkActionCreator, getFetchedAt } = setup(async () => value);
101 const store = getNewStore();
103 expect(selectState(store.getState())).toEqual({
106 meta: { fetchedAt: 0, fetchedEphemeral: undefined },
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({
117 meta: { fetchedAt: 1, fetchedEphemeral },
119 expect(actions[2]).toEqual(thunkActionCreator.fulfilled(value));
122 test("successfully returns previous promise when it's pending", async () => {
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);
133 const promise3 = store.dispatch(thunkActionCreator.thunk());
134 expect(promise1).not.toBe(promise3);
137 test("successfully returns previous value when it's been set", async () => {
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);
151 test("successfully re-fetches when it's expired", async () => {
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);
168 test('successfully rejects with an error', async () => {
169 const error = new Error('Something went wrong');
170 const { getNewStore, actions, thunkActionCreator, getFetchedAt } = setup(async () => {
174 const store = getNewStore();
175 getFetchedAt.mockImplementation(() => 1);
177 expect(selectState(store.getState())).toEqual({
180 meta: { fetchedAt: 0, fetchedEphemeral: undefined },
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({
190 error: serializedError,
191 meta: { fetchedAt: 1, fetchedEphemeral: undefined },
193 expect(actions[2]).toEqual(thunkActionCreator.rejected(serializedError));
196 test('successfully retries after a failure', async () => {
198 const error = new Error('Something went wrong');
200 const { fetchedEphemeral, getNewStore, actions, thunkActionCreator, getFetchedAt } = setup(async () => {
207 const store = getNewStore();
208 getFetchedAt.mockImplementation(() => 1);
210 expect(selectState(store.getState())).toEqual({
213 meta: { fetchedAt: 0, fetchedEphemeral: undefined },
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({
223 error: serializedError,
224 meta: { fetchedAt: 1, fetchedEphemeral: undefined },
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({
233 meta: { fetchedAt: 1, fetchedEphemeral: undefined },
235 expect(actions[4]).toEqual(thunkActionCreator.pending());
237 await expect(successfulPromise).resolves.toEqual(value);
239 expect(selectState(store.getState())).toEqual({
242 meta: { fetchedAt: 2, fetchedEphemeral },
244 expect(actions[5]).toEqual(thunkActionCreator.fulfilled(value));
247 test('successfully re-fetches when fetchedEphemeral is unset', async () => {
248 const fn = jest.fn(async () => 43);
249 const { getNewStore, thunkActionCreator, getFetchedAt } = setup(fn, {
254 fetchedEphemeral: undefined,
257 getFetchedAt.mockImplementation(() => 1);
258 const store = getNewStore();
260 await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(43);
261 expect(selectState(store.getState())).toEqual({
264 meta: { fetchedAt: 1, fetchedEphemeral: true },
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, {
276 fetchedEphemeral: true,
279 getFetchedAt.mockImplementation(() => 1);
280 const store = getNewStore();
282 await expect(store.dispatch(thunkActionCreator.thunk())).resolves.toEqual(42);
283 expect(selectState(store.getState())).toEqual({
286 meta: { fetchedAt: now, fetchedEphemeral: true },