UNPKG

20.8 kBPlain TextView Raw
1import {
2 createAsyncThunk,
3 miniSerializeError,
4 unwrapResult
5} from './createAsyncThunk'
6import { configureStore } from './configureStore'
7import { AnyAction } from 'redux'
8
9import {
10 mockConsole,
11 createConsole,
12 getLog
13} from 'console-testing-library/pure'
14
15declare global {
16 interface Window {
17 AbortController: AbortController
18 }
19}
20
21describe('createAsyncThunk', () => {
22 it('creates the action types', () => {
23 const thunkActionCreator = createAsyncThunk('testType', async () => 42)
24
25 expect(thunkActionCreator.fulfilled.type).toBe('testType/fulfilled')
26 expect(thunkActionCreator.pending.type).toBe('testType/pending')
27 expect(thunkActionCreator.rejected.type).toBe('testType/rejected')
28 })
29
30 it('exposes the typePrefix it was created with', () => {
31 const thunkActionCreator = createAsyncThunk('testType', async () => 42)
32
33 expect(thunkActionCreator.typePrefix).toBe('testType')
34 })
35
36 it('works without passing arguments to the payload creator', async () => {
37 const thunkActionCreator = createAsyncThunk('testType', async () => 42)
38
39 let timesReducerCalled = 0
40
41 const reducer = () => {
42 timesReducerCalled++
43 }
44
45 const store = configureStore({
46 reducer
47 })
48
49 // reset from however many times the store called it
50 timesReducerCalled = 0
51
52 await store.dispatch(thunkActionCreator())
53
54 expect(timesReducerCalled).toBe(2)
55 })
56
57 it('accepts arguments and dispatches the actions on resolve', async () => {
58 const dispatch = jest.fn()
59
60 let passedArg: any
61
62 const result = 42
63 const args = 123
64 let generatedRequestId = ''
65
66 const thunkActionCreator = createAsyncThunk(
67 'testType',
68 async (arg: number, { requestId }) => {
69 passedArg = arg
70 generatedRequestId = requestId
71 return result
72 }
73 )
74
75 const thunkFunction = thunkActionCreator(args)
76
77 const thunkPromise = thunkFunction(dispatch, () => {}, undefined)
78
79 expect(thunkPromise.requestId).toBe(generatedRequestId)
80 expect(thunkPromise.arg).toBe(args)
81
82 await thunkPromise
83
84 expect(passedArg).toBe(args)
85
86 expect(dispatch).toHaveBeenNthCalledWith(
87 1,
88 thunkActionCreator.pending(generatedRequestId, args)
89 )
90
91 expect(dispatch).toHaveBeenNthCalledWith(
92 2,
93 thunkActionCreator.fulfilled(result, generatedRequestId, args)
94 )
95 })
96
97 it('accepts arguments and dispatches the actions on reject', async () => {
98 const dispatch = jest.fn()
99
100 const args = 123
101 let generatedRequestId = ''
102
103 const error = new Error('Panic!')
104
105 const thunkActionCreator = createAsyncThunk(
106 'testType',
107 async (args: number, { requestId }) => {
108 generatedRequestId = requestId
109 throw error
110 }
111 )
112
113 const thunkFunction = thunkActionCreator(args)
114
115 try {
116 await thunkFunction(dispatch, () => {}, undefined)
117 } catch (e) {}
118
119 expect(dispatch).toHaveBeenNthCalledWith(
120 1,
121 thunkActionCreator.pending(generatedRequestId, args)
122 )
123
124 expect(dispatch).toHaveBeenCalledTimes(2)
125
126 // Have to check the bits of the action separately since the error was processed
127 const errorAction = dispatch.mock.calls[1][0]
128 expect(errorAction.error).toEqual(miniSerializeError(error))
129 expect(errorAction.meta.requestId).toBe(generatedRequestId)
130 expect(errorAction.meta.arg).toBe(args)
131 })
132
133 it('dispatches an empty error when throwing a random object without serializedError properties', async () => {
134 const dispatch = jest.fn()
135
136 const args = 123
137 let generatedRequestId = ''
138
139 const errorObject = { wny: 'dothis' }
140
141 const thunkActionCreator = createAsyncThunk(
142 'testType',
143 async (args: number, { requestId }) => {
144 generatedRequestId = requestId
145 throw errorObject
146 }
147 )
148
149 const thunkFunction = thunkActionCreator(args)
150
151 try {
152 await thunkFunction(dispatch, () => {}, undefined)
153 } catch (e) {}
154
155 expect(dispatch).toHaveBeenNthCalledWith(
156 1,
157 thunkActionCreator.pending(generatedRequestId, args)
158 )
159
160 expect(dispatch).toHaveBeenCalledTimes(2)
161
162 const errorAction = dispatch.mock.calls[1][0]
163 expect(errorAction.error).toEqual({})
164 expect(errorAction.meta.requestId).toBe(generatedRequestId)
165 expect(errorAction.meta.arg).toBe(args)
166 })
167
168 it('dispatches an action with a formatted error when throwing an object with known error keys', async () => {
169 const dispatch = jest.fn()
170
171 const args = 123
172 let generatedRequestId = ''
173
174 const errorObject = {
175 name: 'Custom thrown error',
176 message: 'This is not necessary',
177 code: '400'
178 }
179
180 const thunkActionCreator = createAsyncThunk(
181 'testType',
182 async (args: number, { requestId }) => {
183 generatedRequestId = requestId
184 throw errorObject
185 }
186 )
187
188 const thunkFunction = thunkActionCreator(args)
189
190 try {
191 await thunkFunction(dispatch, () => {}, undefined)
192 } catch (e) {}
193
194 expect(dispatch).toHaveBeenNthCalledWith(
195 1,
196 thunkActionCreator.pending(generatedRequestId, args)
197 )
198
199 expect(dispatch).toHaveBeenCalledTimes(2)
200
201 // Have to check the bits of the action separately since the error was processed
202 const errorAction = dispatch.mock.calls[1][0]
203 expect(errorAction.error).toEqual(miniSerializeError(errorObject))
204 expect(Object.keys(errorAction.error)).not.toContain('stack')
205 expect(errorAction.meta.requestId).toBe(generatedRequestId)
206 expect(errorAction.meta.arg).toBe(args)
207 })
208
209 it('dispatches a rejected action with a customized payload when a user returns rejectWithValue()', async () => {
210 const dispatch = jest.fn()
211
212 const args = 123
213 let generatedRequestId = ''
214
215 const errorPayload = {
216 errorMessage:
217 'I am a fake server-provided 400 payload with validation details',
218 errors: [
219 { field_one: 'Must be a string' },
220 { field_two: 'Must be a number' }
221 ]
222 }
223
224 const thunkActionCreator = createAsyncThunk(
225 'testType',
226 async (args: number, { requestId, rejectWithValue }) => {
227 generatedRequestId = requestId
228
229 return rejectWithValue(errorPayload)
230 }
231 )
232
233 const thunkFunction = thunkActionCreator(args)
234
235 try {
236 await thunkFunction(dispatch, () => {}, undefined)
237 } catch (e) {}
238
239 expect(dispatch).toHaveBeenNthCalledWith(
240 1,
241 thunkActionCreator.pending(generatedRequestId, args)
242 )
243
244 expect(dispatch).toHaveBeenCalledTimes(2)
245
246 // Have to check the bits of the action separately since the error was processed
247 const errorAction = dispatch.mock.calls[1][0]
248
249 expect(errorAction.error.message).toEqual('Rejected')
250 expect(errorAction.payload).toBe(errorPayload)
251 expect(errorAction.meta.arg).toBe(args)
252 })
253
254 it('dispatches a rejected action with a miniSerializeError when rejectWithValue conditions are not satisfied', async () => {
255 const dispatch = jest.fn()
256
257 const args = 123
258 let generatedRequestId = ''
259
260 const error = new Error('Panic!')
261
262 const errorPayload = {
263 errorMessage:
264 'I am a fake server-provided 400 payload with validation details',
265 errors: [
266 { field_one: 'Must be a string' },
267 { field_two: 'Must be a number' }
268 ]
269 }
270
271 const thunkActionCreator = createAsyncThunk(
272 'testType',
273 async (args: number, { requestId, rejectWithValue }) => {
274 generatedRequestId = requestId
275
276 try {
277 throw error
278 } catch (err) {
279 if (!err.response) {
280 throw err
281 }
282 return rejectWithValue(errorPayload)
283 }
284 }
285 )
286
287 const thunkFunction = thunkActionCreator(args)
288
289 try {
290 await thunkFunction(dispatch, () => {}, undefined)
291 } catch (e) {}
292
293 expect(dispatch).toHaveBeenNthCalledWith(
294 1,
295 thunkActionCreator.pending(generatedRequestId, args)
296 )
297
298 expect(dispatch).toHaveBeenCalledTimes(2)
299
300 // Have to check the bits of the action separately since the error was processed
301 const errorAction = dispatch.mock.calls[1][0]
302 expect(errorAction.error).toEqual(miniSerializeError(error))
303 expect(errorAction.payload).toEqual(undefined)
304 expect(errorAction.meta.requestId).toBe(generatedRequestId)
305 expect(errorAction.meta.arg).toBe(args)
306 })
307})
308
309describe('createAsyncThunk with abortController', () => {
310 const asyncThunk = createAsyncThunk('test', function abortablePayloadCreator(
311 _: any,
312 { signal }
313 ) {
314 return new Promise((resolve, reject) => {
315 if (signal.aborted) {
316 reject(
317 new DOMException(
318 'This should never be reached as it should already be handled.',
319 'AbortError'
320 )
321 )
322 }
323 signal.addEventListener('abort', () => {
324 reject(new DOMException('Was aborted while running', 'AbortError'))
325 })
326 setTimeout(resolve, 100)
327 })
328 })
329
330 let store = configureStore({
331 reducer(store: AnyAction[] = []) {
332 return store
333 }
334 })
335
336 beforeEach(() => {
337 store = configureStore({
338 reducer(store: AnyAction[] = [], action) {
339 return [...store, action]
340 }
341 })
342 })
343
344 test('normal usage', async () => {
345 await store.dispatch(asyncThunk({}))
346 expect(store.getState()).toEqual([
347 expect.any(Object),
348 expect.objectContaining({ type: 'test/pending' }),
349 expect.objectContaining({ type: 'test/fulfilled' })
350 ])
351 })
352
353 test('abort after dispatch', async () => {
354 const promise = store.dispatch(asyncThunk({}))
355 promise.abort('AbortReason')
356 const result = await promise
357 const expectedAbortedAction = {
358 type: 'test/rejected',
359 error: {
360 message: 'AbortReason',
361 name: 'AbortError'
362 },
363 meta: { aborted: true, requestId: promise.requestId }
364 }
365
366 // abortedAction with reason is dispatched after test/pending is dispatched
367 expect(store.getState()).toMatchObject([
368 {},
369 { type: 'test/pending' },
370 expectedAbortedAction
371 ])
372
373 // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
374 expect(result).toMatchObject(expectedAbortedAction)
375
376 // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
377 expect(() => unwrapResult(result)).toThrowError(
378 expect.objectContaining(expectedAbortedAction.error)
379 )
380 })
381
382 test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
383 const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
384 await new Promise(resolve => setTimeout(resolve, 100))
385 return 'finished'
386 })
387
388 const promise = store.dispatch(unawareAsyncThunk())
389 promise.abort('AbortReason')
390 const result = await promise
391
392 const expectedAbortedAction = {
393 type: 'unaware/rejected',
394 error: {
395 message: 'AbortReason',
396 name: 'AbortError'
397 }
398 }
399
400 // abortedAction with reason is dispatched after test/pending is dispatched
401 expect(store.getState()).toEqual([
402 expect.any(Object),
403 expect.objectContaining({ type: 'unaware/pending' }),
404 expect.objectContaining(expectedAbortedAction)
405 ])
406
407 // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
408 expect(result).toMatchObject(expectedAbortedAction)
409
410 // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
411 expect(() => unwrapResult(result)).toThrowError(
412 expect.objectContaining(expectedAbortedAction.error)
413 )
414 })
415
416 test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => {
417 let running = false
418 const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => {
419 running = true
420 await new Promise(resolve => setTimeout(resolve, 30000))
421 running = false
422 })
423
424 const promise = store.dispatch(longRunningAsyncThunk())
425 expect(running).toBeTruthy()
426 promise.abort()
427 const result = await promise
428 expect(running).toBeTruthy()
429 expect(result).toMatchObject({
430 type: 'longRunning/rejected',
431 error: { message: 'Aborted', name: 'AbortError' },
432 meta: { aborted: true }
433 })
434 })
435
436 describe('behaviour with missing AbortController', () => {
437 let keepAbortController: typeof window['AbortController']
438 let freshlyLoadedModule: typeof import('./createAsyncThunk')
439 let restore: () => void
440 let nodeEnv: string
441
442 beforeEach(() => {
443 keepAbortController = window.AbortController
444 delete (window as any).AbortController
445 jest.resetModules()
446 freshlyLoadedModule = require('./createAsyncThunk')
447 restore = mockConsole(createConsole())
448 nodeEnv = process.env.NODE_ENV!
449 process.env.NODE_ENV = 'development'
450 })
451
452 afterEach(() => {
453 process.env.NODE_ENV = nodeEnv
454 restore()
455 window.AbortController = keepAbortController
456 jest.resetModules()
457 })
458
459 test('calling `abort` on an asyncThunk works with a FallbackAbortController if no global abortController is not available', async () => {
460 const longRunningAsyncThunk = freshlyLoadedModule.createAsyncThunk(
461 'longRunning',
462 async () => {
463 await new Promise(resolve => setTimeout(resolve, 30000))
464 }
465 )
466
467 store.dispatch(longRunningAsyncThunk()).abort()
468 // should only log once, even if called twice
469 store.dispatch(longRunningAsyncThunk()).abort()
470
471 expect(getLog().log).toMatchInlineSnapshot(`
472 "This platform does not implement AbortController.
473 If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'."
474 `)
475 })
476 })
477})
478
479test('non-serializable arguments are ignored by serializableStateInvariantMiddleware', async () => {
480 const restore = mockConsole(createConsole())
481 const nonSerializableValue = new Map()
482 const asyncThunk = createAsyncThunk('test', (arg: Map<any, any>) => {})
483
484 configureStore({
485 reducer: () => 0
486 }).dispatch(asyncThunk(nonSerializableValue))
487
488 expect(getLog().log).toMatchInlineSnapshot(`""`)
489 restore()
490})
491
492describe('conditional skipping of asyncThunks', () => {
493 const arg = {}
494 const getState = jest.fn(() => ({}))
495 const dispatch = jest.fn((x: any) => x)
496 const payloadCreator = jest.fn((x: typeof arg) => 10)
497 const condition = jest.fn(() => false)
498 const extra = {}
499
500 beforeEach(() => {
501 getState.mockClear()
502 dispatch.mockClear()
503 payloadCreator.mockClear()
504 condition.mockClear()
505 })
506
507 test('returning false from condition skips payloadCreator and returns a rejected action', async () => {
508 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
509 const result = await asyncThunk(arg)(dispatch, getState, extra)
510
511 expect(condition).toHaveBeenCalled()
512 expect(payloadCreator).not.toHaveBeenCalled()
513 expect(asyncThunk.rejected.match(result)).toBe(true)
514 expect((result as any).meta.condition).toBe(true)
515 })
516
517 test('return falsy from condition does not skip payload creator', async () => {
518 // Override TS's expectation that this is a boolean
519 condition.mockReturnValueOnce((undefined as unknown) as boolean)
520 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
521 const result = await asyncThunk(arg)(dispatch, getState, extra)
522
523 expect(condition).toHaveBeenCalled()
524 expect(payloadCreator).toHaveBeenCalled()
525 expect(asyncThunk.fulfilled.match(result)).toBe(true)
526 expect(result.payload).toBe(10)
527 })
528
529 test('returning true from condition executes payloadCreator', async () => {
530 condition.mockReturnValueOnce(true)
531 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
532 const result = await asyncThunk(arg)(dispatch, getState, extra)
533
534 expect(condition).toHaveBeenCalled()
535 expect(payloadCreator).toHaveBeenCalled()
536 expect(asyncThunk.fulfilled.match(result)).toBe(true)
537 expect(result.payload).toBe(10)
538 })
539
540 test('condition is called with arg, getState and extra', async () => {
541 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
542 await asyncThunk(arg)(dispatch, getState, extra)
543
544 expect(condition).toHaveBeenCalledTimes(1)
545 expect(condition).toHaveBeenLastCalledWith(
546 arg,
547 expect.objectContaining({ getState, extra })
548 )
549 })
550
551 test('rejected action is not dispatched by default', async () => {
552 const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
553 await asyncThunk(arg)(dispatch, getState, extra)
554
555 expect(dispatch).toHaveBeenCalledTimes(0)
556 })
557
558 test('does not fail when attempting to abort a canceled promise', async () => {
559 const asyncPayloadCreator = jest.fn(async (x: typeof arg) => {
560 await new Promise(resolve => setTimeout(resolve, 2000))
561 return 10
562 })
563
564 const asyncThunk = createAsyncThunk('test', asyncPayloadCreator, {
565 condition
566 })
567 const promise = asyncThunk(arg)(dispatch, getState, extra)
568 promise.abort(
569 `If the promise was 1. somehow canceled, 2. in a 'started' state and 3. we attempted to abort, this would crash the tests`
570 )
571 })
572
573 test('rejected action can be dispatched via option', async () => {
574 const asyncThunk = createAsyncThunk('test', payloadCreator, {
575 condition,
576 dispatchConditionRejection: true
577 })
578 await asyncThunk(arg)(dispatch, getState, extra)
579
580 expect(dispatch).toHaveBeenCalledTimes(1)
581 expect(dispatch).toHaveBeenLastCalledWith(
582 expect.objectContaining({
583 error: {
584 message: 'Aborted due to condition callback returning false.',
585 name: 'ConditionError'
586 },
587 meta: {
588 aborted: false,
589 arg: arg,
590 rejectedWithValue: false,
591 condition: true,
592 requestId: expect.stringContaining(''),
593 requestStatus: 'rejected'
594 },
595 payload: undefined,
596 type: 'test/rejected'
597 })
598 )
599 })
600
601 test('serializeError implementation', async () => {
602 function serializeError() {
603 return 'serialized!'
604 }
605 const errorObject = 'something else!'
606
607 const store = configureStore({
608 reducer: (state = [], action) => [...state, action]
609 })
610
611 const asyncThunk = createAsyncThunk<
612 unknown,
613 void,
614 { serializedErrorType: string }
615 >('test', () => Promise.reject(errorObject), { serializeError })
616 const rejected = await store.dispatch(asyncThunk())
617 if (!asyncThunk.rejected.match(rejected)) {
618 throw new Error()
619 }
620
621 const expectation = {
622 type: 'test/rejected',
623 payload: undefined,
624 error: 'serialized!',
625 meta: expect.any(Object)
626 }
627 expect(rejected).toEqual(expectation)
628 expect(store.getState()[2]).toEqual(expectation)
629 expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
630 })
631})
632describe('unwrapResult', () => {
633 const getState = jest.fn(() => ({}))
634 const dispatch = jest.fn((x: any) => x)
635 const extra = {}
636 test('fulfilled case', async () => {
637 const asyncThunk = createAsyncThunk('test', () => {
638 return 'fulfilled!'
639 })
640
641 const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
642 unwrapResult
643 )
644
645 await expect(unwrapPromise).resolves.toBe('fulfilled!')
646 })
647 test('error case', async () => {
648 const error = new Error('Panic!')
649 const asyncThunk = createAsyncThunk('test', () => {
650 throw error
651 })
652
653 const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
654 unwrapResult
655 )
656
657 await expect(unwrapPromise).rejects.toEqual(miniSerializeError(error))
658 })
659 test('rejectWithValue case', async () => {
660 const asyncThunk = createAsyncThunk('test', (_, { rejectWithValue }) => {
661 return rejectWithValue('rejectWithValue!')
662 })
663
664 const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
665 unwrapResult
666 )
667
668 await expect(unwrapPromise).rejects.toBe('rejectWithValue!')
669 })
670})
671
\No newline at end of file