1 | import type { AnyAction } from '@reduxjs/toolkit'
|
2 | import {
|
3 | createAsyncThunk,
|
4 | unwrapResult,
|
5 | configureStore,
|
6 | createReducer,
|
7 | } from '@reduxjs/toolkit'
|
8 | import { miniSerializeError } from '@internal/createAsyncThunk'
|
9 |
|
10 | import {
|
11 | mockConsole,
|
12 | createConsole,
|
13 | getLog,
|
14 | } from 'console-testing-library/pure'
|
15 | import { expectType } from './helpers'
|
16 |
|
17 | declare global {
|
18 | interface Window {
|
19 | AbortController: AbortController
|
20 | }
|
21 | }
|
22 |
|
23 | describe('createAsyncThunk', () => {
|
24 | it('creates the action types', () => {
|
25 | const thunkActionCreator = createAsyncThunk('testType', async () => 42)
|
26 |
|
27 | expect(thunkActionCreator.fulfilled.type).toBe('testType/fulfilled')
|
28 | expect(thunkActionCreator.pending.type).toBe('testType/pending')
|
29 | expect(thunkActionCreator.rejected.type).toBe('testType/rejected')
|
30 | })
|
31 |
|
32 | it('exposes the typePrefix it was created with', () => {
|
33 | const thunkActionCreator = createAsyncThunk('testType', async () => 42)
|
34 |
|
35 | expect(thunkActionCreator.typePrefix).toBe('testType')
|
36 | })
|
37 |
|
38 | it('works without passing arguments to the payload creator', async () => {
|
39 | const thunkActionCreator = createAsyncThunk('testType', async () => 42)
|
40 |
|
41 | let timesReducerCalled = 0
|
42 |
|
43 | const reducer = () => {
|
44 | timesReducerCalled++
|
45 | }
|
46 |
|
47 | const store = configureStore({
|
48 | reducer,
|
49 | })
|
50 |
|
51 |
|
52 | timesReducerCalled = 0
|
53 |
|
54 | await store.dispatch(thunkActionCreator())
|
55 |
|
56 | expect(timesReducerCalled).toBe(2)
|
57 | })
|
58 |
|
59 | it('accepts arguments and dispatches the actions on resolve', async () => {
|
60 | const dispatch = jest.fn()
|
61 |
|
62 | let passedArg: any
|
63 |
|
64 | const result = 42
|
65 | const args = 123
|
66 | let generatedRequestId = ''
|
67 |
|
68 | const thunkActionCreator = createAsyncThunk(
|
69 | 'testType',
|
70 | async (arg: number, { requestId }) => {
|
71 | passedArg = arg
|
72 | generatedRequestId = requestId
|
73 | return result
|
74 | }
|
75 | )
|
76 |
|
77 | const thunkFunction = thunkActionCreator(args)
|
78 |
|
79 | const thunkPromise = thunkFunction(dispatch, () => {}, undefined)
|
80 |
|
81 | expect(thunkPromise.requestId).toBe(generatedRequestId)
|
82 | expect(thunkPromise.arg).toBe(args)
|
83 |
|
84 | await thunkPromise
|
85 |
|
86 | expect(passedArg).toBe(args)
|
87 |
|
88 | expect(dispatch).toHaveBeenNthCalledWith(
|
89 | 1,
|
90 | thunkActionCreator.pending(generatedRequestId, args)
|
91 | )
|
92 |
|
93 | expect(dispatch).toHaveBeenNthCalledWith(
|
94 | 2,
|
95 | thunkActionCreator.fulfilled(result, generatedRequestId, args)
|
96 | )
|
97 | })
|
98 |
|
99 | it('accepts arguments and dispatches the actions on reject', async () => {
|
100 | const dispatch = jest.fn()
|
101 |
|
102 | const args = 123
|
103 | let generatedRequestId = ''
|
104 |
|
105 | const error = new Error('Panic!')
|
106 |
|
107 | const thunkActionCreator = createAsyncThunk(
|
108 | 'testType',
|
109 | async (args: number, { requestId }) => {
|
110 | generatedRequestId = requestId
|
111 | throw error
|
112 | }
|
113 | )
|
114 |
|
115 | const thunkFunction = thunkActionCreator(args)
|
116 |
|
117 | try {
|
118 | await thunkFunction(dispatch, () => {}, undefined)
|
119 | } catch (e) {}
|
120 |
|
121 | expect(dispatch).toHaveBeenNthCalledWith(
|
122 | 1,
|
123 | thunkActionCreator.pending(generatedRequestId, args)
|
124 | )
|
125 |
|
126 | expect(dispatch).toHaveBeenCalledTimes(2)
|
127 |
|
128 |
|
129 | const errorAction = dispatch.mock.calls[1][0]
|
130 | expect(errorAction.error).toEqual(miniSerializeError(error))
|
131 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
132 | expect(errorAction.meta.arg).toBe(args)
|
133 | })
|
134 |
|
135 | it('dispatches an empty error when throwing a random object without serializedError properties', async () => {
|
136 | const dispatch = jest.fn()
|
137 |
|
138 | const args = 123
|
139 | let generatedRequestId = ''
|
140 |
|
141 | const errorObject = { wny: 'dothis' }
|
142 |
|
143 | const thunkActionCreator = createAsyncThunk(
|
144 | 'testType',
|
145 | async (args: number, { requestId }) => {
|
146 | generatedRequestId = requestId
|
147 | throw errorObject
|
148 | }
|
149 | )
|
150 |
|
151 | const thunkFunction = thunkActionCreator(args)
|
152 |
|
153 | try {
|
154 | await thunkFunction(dispatch, () => {}, undefined)
|
155 | } catch (e) {}
|
156 |
|
157 | expect(dispatch).toHaveBeenNthCalledWith(
|
158 | 1,
|
159 | thunkActionCreator.pending(generatedRequestId, args)
|
160 | )
|
161 |
|
162 | expect(dispatch).toHaveBeenCalledTimes(2)
|
163 |
|
164 | const errorAction = dispatch.mock.calls[1][0]
|
165 | expect(errorAction.error).toEqual({})
|
166 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
167 | expect(errorAction.meta.arg).toBe(args)
|
168 | })
|
169 |
|
170 | it('dispatches an action with a formatted error when throwing an object with known error keys', async () => {
|
171 | const dispatch = jest.fn()
|
172 |
|
173 | const args = 123
|
174 | let generatedRequestId = ''
|
175 |
|
176 | const errorObject = {
|
177 | name: 'Custom thrown error',
|
178 | message: 'This is not necessary',
|
179 | code: '400',
|
180 | }
|
181 |
|
182 | const thunkActionCreator = createAsyncThunk(
|
183 | 'testType',
|
184 | async (args: number, { requestId }) => {
|
185 | generatedRequestId = requestId
|
186 | throw errorObject
|
187 | }
|
188 | )
|
189 |
|
190 | const thunkFunction = thunkActionCreator(args)
|
191 |
|
192 | try {
|
193 | await thunkFunction(dispatch, () => {}, undefined)
|
194 | } catch (e) {}
|
195 |
|
196 | expect(dispatch).toHaveBeenNthCalledWith(
|
197 | 1,
|
198 | thunkActionCreator.pending(generatedRequestId, args)
|
199 | )
|
200 |
|
201 | expect(dispatch).toHaveBeenCalledTimes(2)
|
202 |
|
203 |
|
204 | const errorAction = dispatch.mock.calls[1][0]
|
205 | expect(errorAction.error).toEqual(miniSerializeError(errorObject))
|
206 | expect(Object.keys(errorAction.error)).not.toContain('stack')
|
207 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
208 | expect(errorAction.meta.arg).toBe(args)
|
209 | })
|
210 |
|
211 | it('dispatches a rejected action with a customized payload when a user returns rejectWithValue()', async () => {
|
212 | const dispatch = jest.fn()
|
213 |
|
214 | const args = 123
|
215 | let generatedRequestId = ''
|
216 |
|
217 | const errorPayload = {
|
218 | errorMessage:
|
219 | 'I am a fake server-provided 400 payload with validation details',
|
220 | errors: [
|
221 | { field_one: 'Must be a string' },
|
222 | { field_two: 'Must be a number' },
|
223 | ],
|
224 | }
|
225 |
|
226 | const thunkActionCreator = createAsyncThunk(
|
227 | 'testType',
|
228 | async (args: number, { requestId, rejectWithValue }) => {
|
229 | generatedRequestId = requestId
|
230 |
|
231 | return rejectWithValue(errorPayload)
|
232 | }
|
233 | )
|
234 |
|
235 | const thunkFunction = thunkActionCreator(args)
|
236 |
|
237 | try {
|
238 | await thunkFunction(dispatch, () => {}, undefined)
|
239 | } catch (e) {}
|
240 |
|
241 | expect(dispatch).toHaveBeenNthCalledWith(
|
242 | 1,
|
243 | thunkActionCreator.pending(generatedRequestId, args)
|
244 | )
|
245 |
|
246 | expect(dispatch).toHaveBeenCalledTimes(2)
|
247 |
|
248 |
|
249 | const errorAction = dispatch.mock.calls[1][0]
|
250 |
|
251 | expect(errorAction.error.message).toEqual('Rejected')
|
252 | expect(errorAction.payload).toBe(errorPayload)
|
253 | expect(errorAction.meta.arg).toBe(args)
|
254 | })
|
255 |
|
256 | it('dispatches a rejected action with a customized payload when a user throws rejectWithValue()', async () => {
|
257 | const dispatch = jest.fn()
|
258 |
|
259 | const args = 123
|
260 | let generatedRequestId = ''
|
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 | throw rejectWithValue(errorPayload)
|
277 | }
|
278 | )
|
279 |
|
280 | const thunkFunction = thunkActionCreator(args)
|
281 |
|
282 | try {
|
283 | await thunkFunction(dispatch, () => {}, undefined)
|
284 | } catch (e) {}
|
285 |
|
286 | expect(dispatch).toHaveBeenNthCalledWith(
|
287 | 1,
|
288 | thunkActionCreator.pending(generatedRequestId, args)
|
289 | )
|
290 |
|
291 | expect(dispatch).toHaveBeenCalledTimes(2)
|
292 |
|
293 |
|
294 | const errorAction = dispatch.mock.calls[1][0]
|
295 |
|
296 | expect(errorAction.error.message).toEqual('Rejected')
|
297 | expect(errorAction.payload).toBe(errorPayload)
|
298 | expect(errorAction.meta.arg).toBe(args)
|
299 | })
|
300 |
|
301 | it('dispatches a rejected action with a miniSerializeError when rejectWithValue conditions are not satisfied', async () => {
|
302 | const dispatch = jest.fn()
|
303 |
|
304 | const args = 123
|
305 | let generatedRequestId = ''
|
306 |
|
307 | const error = new Error('Panic!')
|
308 |
|
309 | const errorPayload = {
|
310 | errorMessage:
|
311 | 'I am a fake server-provided 400 payload with validation details',
|
312 | errors: [
|
313 | { field_one: 'Must be a string' },
|
314 | { field_two: 'Must be a number' },
|
315 | ],
|
316 | }
|
317 |
|
318 | const thunkActionCreator = createAsyncThunk(
|
319 | 'testType',
|
320 | async (args: number, { requestId, rejectWithValue }) => {
|
321 | generatedRequestId = requestId
|
322 |
|
323 | try {
|
324 | throw error
|
325 | } catch (err) {
|
326 | if (!(err as any).response) {
|
327 | throw err
|
328 | }
|
329 | return rejectWithValue(errorPayload)
|
330 | }
|
331 | }
|
332 | )
|
333 |
|
334 | const thunkFunction = thunkActionCreator(args)
|
335 |
|
336 | try {
|
337 | await thunkFunction(dispatch, () => {}, undefined)
|
338 | } catch (e) {}
|
339 |
|
340 | expect(dispatch).toHaveBeenNthCalledWith(
|
341 | 1,
|
342 | thunkActionCreator.pending(generatedRequestId, args)
|
343 | )
|
344 |
|
345 | expect(dispatch).toHaveBeenCalledTimes(2)
|
346 |
|
347 |
|
348 | const errorAction = dispatch.mock.calls[1][0]
|
349 | expect(errorAction.error).toEqual(miniSerializeError(error))
|
350 | expect(errorAction.payload).toEqual(undefined)
|
351 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
352 | expect(errorAction.meta.arg).toBe(args)
|
353 | })
|
354 | })
|
355 |
|
356 | describe('createAsyncThunk with abortController', () => {
|
357 | const asyncThunk = createAsyncThunk(
|
358 | 'test',
|
359 | function abortablePayloadCreator(_: any, { signal }) {
|
360 | return new Promise((resolve, reject) => {
|
361 | if (signal.aborted) {
|
362 | reject(
|
363 | new DOMException(
|
364 | 'This should never be reached as it should already be handled.',
|
365 | 'AbortError'
|
366 | )
|
367 | )
|
368 | }
|
369 | signal.addEventListener('abort', () => {
|
370 | reject(new DOMException('Was aborted while running', 'AbortError'))
|
371 | })
|
372 | setTimeout(resolve, 100)
|
373 | })
|
374 | }
|
375 | )
|
376 |
|
377 | let store = configureStore({
|
378 | reducer(store: AnyAction[] = []) {
|
379 | return store
|
380 | },
|
381 | })
|
382 |
|
383 | beforeEach(() => {
|
384 | store = configureStore({
|
385 | reducer(store: AnyAction[] = [], action) {
|
386 | return [...store, action]
|
387 | },
|
388 | })
|
389 | })
|
390 |
|
391 | test('normal usage', async () => {
|
392 | await store.dispatch(asyncThunk({}))
|
393 | expect(store.getState()).toEqual([
|
394 | expect.any(Object),
|
395 | expect.objectContaining({ type: 'test/pending' }),
|
396 | expect.objectContaining({ type: 'test/fulfilled' }),
|
397 | ])
|
398 | })
|
399 |
|
400 | test('abort after dispatch', async () => {
|
401 | const promise = store.dispatch(asyncThunk({}))
|
402 | promise.abort('AbortReason')
|
403 | const result = await promise
|
404 | const expectedAbortedAction = {
|
405 | type: 'test/rejected',
|
406 | error: {
|
407 | message: 'AbortReason',
|
408 | name: 'AbortError',
|
409 | },
|
410 | meta: { aborted: true, requestId: promise.requestId },
|
411 | }
|
412 |
|
413 |
|
414 | expect(store.getState()).toMatchObject([
|
415 | {},
|
416 | { type: 'test/pending' },
|
417 | expectedAbortedAction,
|
418 | ])
|
419 |
|
420 |
|
421 | expect(result).toMatchObject(expectedAbortedAction)
|
422 |
|
423 |
|
424 | expect(() => unwrapResult(result)).toThrowError(
|
425 | expect.objectContaining(expectedAbortedAction.error)
|
426 | )
|
427 | })
|
428 |
|
429 | test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
|
430 | const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
|
431 | await new Promise((resolve) => setTimeout(resolve, 100))
|
432 | return 'finished'
|
433 | })
|
434 |
|
435 | const promise = store.dispatch(unawareAsyncThunk())
|
436 | promise.abort('AbortReason')
|
437 | const result = await promise
|
438 |
|
439 | const expectedAbortedAction = {
|
440 | type: 'unaware/rejected',
|
441 | error: {
|
442 | message: 'AbortReason',
|
443 | name: 'AbortError',
|
444 | },
|
445 | }
|
446 |
|
447 |
|
448 | expect(store.getState()).toEqual([
|
449 | expect.any(Object),
|
450 | expect.objectContaining({ type: 'unaware/pending' }),
|
451 | expect.objectContaining(expectedAbortedAction),
|
452 | ])
|
453 |
|
454 |
|
455 | expect(result).toMatchObject(expectedAbortedAction)
|
456 |
|
457 |
|
458 | expect(() => unwrapResult(result)).toThrowError(
|
459 | expect.objectContaining(expectedAbortedAction.error)
|
460 | )
|
461 | })
|
462 |
|
463 | test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => {
|
464 | let running = false
|
465 | const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => {
|
466 | running = true
|
467 | await new Promise((resolve) => setTimeout(resolve, 30000))
|
468 | running = false
|
469 | })
|
470 |
|
471 | const promise = store.dispatch(longRunningAsyncThunk())
|
472 | expect(running).toBeTruthy()
|
473 | promise.abort()
|
474 | const result = await promise
|
475 | expect(running).toBeTruthy()
|
476 | expect(result).toMatchObject({
|
477 | type: 'longRunning/rejected',
|
478 | error: { message: 'Aborted', name: 'AbortError' },
|
479 | meta: { aborted: true },
|
480 | })
|
481 | })
|
482 |
|
483 | describe('behaviour with missing AbortController', () => {
|
484 | let keepAbortController: typeof window['AbortController']
|
485 | let freshlyLoadedModule: typeof import('../createAsyncThunk')
|
486 | let restore: () => void
|
487 | let nodeEnv: string
|
488 |
|
489 | beforeEach(() => {
|
490 | keepAbortController = window.AbortController
|
491 | delete (window as any).AbortController
|
492 | jest.resetModules()
|
493 | freshlyLoadedModule = require('../createAsyncThunk')
|
494 | restore = mockConsole(createConsole())
|
495 | nodeEnv = process.env.NODE_ENV!
|
496 | ;(process.env as any).NODE_ENV = 'development'
|
497 | })
|
498 |
|
499 | afterEach(() => {
|
500 | ;(process.env as any).NODE_ENV = nodeEnv
|
501 | restore()
|
502 | window.AbortController = keepAbortController
|
503 | jest.resetModules()
|
504 | })
|
505 |
|
506 | test('calling `abort` on an asyncThunk works with a FallbackAbortController if no global abortController is not available', async () => {
|
507 | const longRunningAsyncThunk = freshlyLoadedModule.createAsyncThunk(
|
508 | 'longRunning',
|
509 | async () => {
|
510 | await new Promise((resolve) => setTimeout(resolve, 30000))
|
511 | }
|
512 | )
|
513 |
|
514 | store.dispatch(longRunningAsyncThunk()).abort()
|
515 |
|
516 | store.dispatch(longRunningAsyncThunk()).abort()
|
517 |
|
518 | expect(getLog().log).toMatchInlineSnapshot(`
|
519 | "This platform does not implement AbortController.
|
520 | If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'."
|
521 | `)
|
522 | })
|
523 | })
|
524 | })
|
525 |
|
526 | test('non-serializable arguments are ignored by serializableStateInvariantMiddleware', async () => {
|
527 | const restore = mockConsole(createConsole())
|
528 | const nonSerializableValue = new Map()
|
529 | const asyncThunk = createAsyncThunk('test', (arg: Map<any, any>) => {})
|
530 |
|
531 | configureStore({
|
532 | reducer: () => 0,
|
533 | }).dispatch(asyncThunk(nonSerializableValue))
|
534 |
|
535 | expect(getLog().log).toMatchInlineSnapshot(`""`)
|
536 | restore()
|
537 | })
|
538 |
|
539 | describe('conditional skipping of asyncThunks', () => {
|
540 | const arg = {}
|
541 | const getState = jest.fn(() => ({}))
|
542 | const dispatch = jest.fn((x: any) => x)
|
543 | const payloadCreator = jest.fn((x: typeof arg) => 10)
|
544 | const condition = jest.fn(() => false)
|
545 | const extra = {}
|
546 |
|
547 | beforeEach(() => {
|
548 | getState.mockClear()
|
549 | dispatch.mockClear()
|
550 | payloadCreator.mockClear()
|
551 | condition.mockClear()
|
552 | })
|
553 |
|
554 | test('returning false from condition skips payloadCreator and returns a rejected action', async () => {
|
555 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
556 | const result = await asyncThunk(arg)(dispatch, getState, extra)
|
557 |
|
558 | expect(condition).toHaveBeenCalled()
|
559 | expect(payloadCreator).not.toHaveBeenCalled()
|
560 | expect(asyncThunk.rejected.match(result)).toBe(true)
|
561 | expect((result as any).meta.condition).toBe(true)
|
562 | })
|
563 |
|
564 | test('return falsy from condition does not skip payload creator', async () => {
|
565 |
|
566 | condition.mockReturnValueOnce(undefined as unknown as boolean)
|
567 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
568 | const result = await asyncThunk(arg)(dispatch, getState, extra)
|
569 |
|
570 | expect(condition).toHaveBeenCalled()
|
571 | expect(payloadCreator).toHaveBeenCalled()
|
572 | expect(asyncThunk.fulfilled.match(result)).toBe(true)
|
573 | expect(result.payload).toBe(10)
|
574 | })
|
575 |
|
576 | test('returning true from condition executes payloadCreator', async () => {
|
577 | condition.mockReturnValueOnce(true)
|
578 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
579 | const result = await asyncThunk(arg)(dispatch, getState, extra)
|
580 |
|
581 | expect(condition).toHaveBeenCalled()
|
582 | expect(payloadCreator).toHaveBeenCalled()
|
583 | expect(asyncThunk.fulfilled.match(result)).toBe(true)
|
584 | expect(result.payload).toBe(10)
|
585 | })
|
586 |
|
587 | test('condition is called with arg, getState and extra', async () => {
|
588 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
589 | await asyncThunk(arg)(dispatch, getState, extra)
|
590 |
|
591 | expect(condition).toHaveBeenCalledTimes(1)
|
592 | expect(condition).toHaveBeenLastCalledWith(
|
593 | arg,
|
594 | expect.objectContaining({ getState, extra })
|
595 | )
|
596 | })
|
597 |
|
598 | test('rejected action is not dispatched by default', async () => {
|
599 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
600 | await asyncThunk(arg)(dispatch, getState, extra)
|
601 |
|
602 | expect(dispatch).toHaveBeenCalledTimes(0)
|
603 | })
|
604 |
|
605 | test('does not fail when attempting to abort a canceled promise', async () => {
|
606 | const asyncPayloadCreator = jest.fn(async (x: typeof arg) => {
|
607 | await new Promise((resolve) => setTimeout(resolve, 2000))
|
608 | return 10
|
609 | })
|
610 |
|
611 | const asyncThunk = createAsyncThunk('test', asyncPayloadCreator, {
|
612 | condition,
|
613 | })
|
614 | const promise = asyncThunk(arg)(dispatch, getState, extra)
|
615 | promise.abort(
|
616 | `If the promise was 1. somehow canceled, 2. in a 'started' state and 3. we attempted to abort, this would crash the tests`
|
617 | )
|
618 | })
|
619 |
|
620 | test('rejected action can be dispatched via option', async () => {
|
621 | const asyncThunk = createAsyncThunk('test', payloadCreator, {
|
622 | condition,
|
623 | dispatchConditionRejection: true,
|
624 | })
|
625 | await asyncThunk(arg)(dispatch, getState, extra)
|
626 |
|
627 | expect(dispatch).toHaveBeenCalledTimes(1)
|
628 | expect(dispatch).toHaveBeenLastCalledWith(
|
629 | expect.objectContaining({
|
630 | error: {
|
631 | message: 'Aborted due to condition callback returning false.',
|
632 | name: 'ConditionError',
|
633 | },
|
634 | meta: {
|
635 | aborted: false,
|
636 | arg: arg,
|
637 | rejectedWithValue: false,
|
638 | condition: true,
|
639 | requestId: expect.stringContaining(''),
|
640 | requestStatus: 'rejected',
|
641 | },
|
642 | payload: undefined,
|
643 | type: 'test/rejected',
|
644 | })
|
645 | )
|
646 | })
|
647 |
|
648 | test('serializeError implementation', async () => {
|
649 | function serializeError() {
|
650 | return 'serialized!'
|
651 | }
|
652 | const errorObject = 'something else!'
|
653 |
|
654 | const store = configureStore({
|
655 | reducer: (state = [], action) => [...state, action],
|
656 | })
|
657 |
|
658 | const asyncThunk = createAsyncThunk<
|
659 | unknown,
|
660 | void,
|
661 | { serializedErrorType: string }
|
662 | >('test', () => Promise.reject(errorObject), { serializeError })
|
663 | const rejected = await store.dispatch(asyncThunk())
|
664 | if (!asyncThunk.rejected.match(rejected)) {
|
665 | throw new Error()
|
666 | }
|
667 |
|
668 | const expectation = {
|
669 | type: 'test/rejected',
|
670 | payload: undefined,
|
671 | error: 'serialized!',
|
672 | meta: expect.any(Object),
|
673 | }
|
674 | expect(rejected).toEqual(expectation)
|
675 | expect(store.getState()[2]).toEqual(expectation)
|
676 | expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
|
677 | })
|
678 | })
|
679 | describe('unwrapResult', () => {
|
680 | const getState = jest.fn(() => ({}))
|
681 | const dispatch = jest.fn((x: any) => x)
|
682 | const extra = {}
|
683 | test('fulfilled case', async () => {
|
684 | const asyncThunk = createAsyncThunk('test', () => {
|
685 | return 'fulfilled!' as const
|
686 | })
|
687 |
|
688 | const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
|
689 | unwrapResult
|
690 | )
|
691 |
|
692 | await expect(unwrapPromise).resolves.toBe('fulfilled!')
|
693 |
|
694 | const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
|
695 | const res = await unwrapPromise2.unwrap()
|
696 | expect(res).toBe('fulfilled!')
|
697 | })
|
698 | test('error case', async () => {
|
699 | const error = new Error('Panic!')
|
700 | const asyncThunk = createAsyncThunk('test', () => {
|
701 | throw error
|
702 | })
|
703 |
|
704 | const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
|
705 | unwrapResult
|
706 | )
|
707 |
|
708 | await expect(unwrapPromise).rejects.toEqual(miniSerializeError(error))
|
709 |
|
710 | const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
|
711 | await expect(unwrapPromise2.unwrap()).rejects.toEqual(
|
712 | miniSerializeError(error)
|
713 | )
|
714 | })
|
715 | test('rejectWithValue case', async () => {
|
716 | const asyncThunk = createAsyncThunk('test', (_, { rejectWithValue }) => {
|
717 | return rejectWithValue('rejectWithValue!')
|
718 | })
|
719 |
|
720 | const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
|
721 | unwrapResult
|
722 | )
|
723 |
|
724 | await expect(unwrapPromise).rejects.toBe('rejectWithValue!')
|
725 |
|
726 | const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
|
727 | await expect(unwrapPromise2.unwrap()).rejects.toBe('rejectWithValue!')
|
728 | })
|
729 | })
|
730 |
|
731 | describe('idGenerator option', () => {
|
732 | const getState = () => ({})
|
733 | const dispatch = (x: any) => x
|
734 | const extra = {}
|
735 |
|
736 | test('idGenerator implementation - can customizes how request IDs are generated', async () => {
|
737 | function makeFakeIdGenerator() {
|
738 | let id = 0
|
739 | return jest.fn(() => {
|
740 | id++
|
741 | return `fake-random-id-${id}`
|
742 | })
|
743 | }
|
744 |
|
745 | let generatedRequestId = ''
|
746 |
|
747 | const idGenerator = makeFakeIdGenerator()
|
748 | const asyncThunk = createAsyncThunk(
|
749 | 'test',
|
750 | async (args: void, { requestId }) => {
|
751 | generatedRequestId = requestId
|
752 | },
|
753 | { idGenerator }
|
754 | )
|
755 |
|
756 |
|
757 | const promise0 = asyncThunk()(dispatch, getState, extra)
|
758 | expect(generatedRequestId).toEqual('fake-random-id-1')
|
759 | expect(promise0.requestId).toEqual('fake-random-id-1')
|
760 | expect((await promise0).meta.requestId).toEqual('fake-random-id-1')
|
761 |
|
762 | const promise1 = asyncThunk()(dispatch, getState, extra)
|
763 | expect(generatedRequestId).toEqual('fake-random-id-2')
|
764 | expect(promise1.requestId).toEqual('fake-random-id-2')
|
765 | expect((await promise1).meta.requestId).toEqual('fake-random-id-2')
|
766 |
|
767 | const promise2 = asyncThunk()(dispatch, getState, extra)
|
768 | expect(generatedRequestId).toEqual('fake-random-id-3')
|
769 | expect(promise2.requestId).toEqual('fake-random-id-3')
|
770 | expect((await promise2).meta.requestId).toEqual('fake-random-id-3')
|
771 |
|
772 | generatedRequestId = ''
|
773 | const defaultAsyncThunk = createAsyncThunk(
|
774 | 'test',
|
775 | async (args: void, { requestId }) => {
|
776 | generatedRequestId = requestId
|
777 | }
|
778 | )
|
779 |
|
780 |
|
781 | const promise3 = defaultAsyncThunk()(dispatch, getState, extra)
|
782 | expect(generatedRequestId).toEqual(promise3.requestId)
|
783 | expect(promise3.requestId).not.toEqual('')
|
784 | expect(promise3.requestId).not.toEqual(
|
785 | expect.stringContaining('fake-random-id')
|
786 | )
|
787 | expect((await promise3).meta.requestId).not.toEqual(
|
788 | expect.stringContaining('fake-fandom-id')
|
789 | )
|
790 | })
|
791 | })
|
792 |
|
793 | test('`condition` will see state changes from a synchonously invoked asyncThunk', () => {
|
794 | type State = ReturnType<typeof store.getState>
|
795 | const onStart = jest.fn()
|
796 | const asyncThunk = createAsyncThunk<
|
797 | void,
|
798 | { force?: boolean },
|
799 | { state: State }
|
800 | >('test', onStart, {
|
801 | condition({ force }, { getState }) {
|
802 | return force || !getState().started
|
803 | },
|
804 | })
|
805 | const store = configureStore({
|
806 | reducer: createReducer({ started: false }, (builder) => {
|
807 | builder.addCase(asyncThunk.pending, (state) => {
|
808 | state.started = true
|
809 | })
|
810 | }),
|
811 | })
|
812 |
|
813 | store.dispatch(asyncThunk({ force: false }))
|
814 | expect(onStart).toHaveBeenCalledTimes(1)
|
815 | store.dispatch(asyncThunk({ force: false }))
|
816 | expect(onStart).toHaveBeenCalledTimes(1)
|
817 | store.dispatch(asyncThunk({ force: true }))
|
818 | expect(onStart).toHaveBeenCalledTimes(2)
|
819 | })
|
820 |
|
821 | describe('meta', () => {
|
822 | const getNewStore = () =>
|
823 | configureStore({
|
824 | reducer(actions = [], action) {
|
825 | return [...actions, action]
|
826 | },
|
827 | })
|
828 | let store = getNewStore()
|
829 |
|
830 | beforeEach(() => {
|
831 | const store = getNewStore()
|
832 | })
|
833 |
|
834 | test('pendingMeta', () => {
|
835 | const pendingThunk = createAsyncThunk('test', (arg: string) => {}, {
|
836 | getPendingMeta({ arg, requestId }) {
|
837 | expect(arg).toBe('testArg')
|
838 | expect(requestId).toEqual(expect.any(String))
|
839 | return { extraProp: 'foo' }
|
840 | },
|
841 | })
|
842 | const ret = store.dispatch(pendingThunk('testArg'))
|
843 | expect(store.getState()[1]).toEqual({
|
844 | meta: {
|
845 | arg: 'testArg',
|
846 | extraProp: 'foo',
|
847 | requestId: ret.requestId,
|
848 | requestStatus: 'pending',
|
849 | },
|
850 | payload: undefined,
|
851 | type: 'test/pending',
|
852 | })
|
853 | })
|
854 |
|
855 | test('fulfilledMeta', async () => {
|
856 | const fulfilledThunk = createAsyncThunk<
|
857 | string,
|
858 | string,
|
859 | { fulfilledMeta: { extraProp: string } }
|
860 | >('test', (arg: string, { fulfillWithValue }) => {
|
861 | return fulfillWithValue('hooray!', { extraProp: 'bar' })
|
862 | })
|
863 | const ret = store.dispatch(fulfilledThunk('testArg'))
|
864 | expect(await ret).toEqual({
|
865 | meta: {
|
866 | arg: 'testArg',
|
867 | extraProp: 'bar',
|
868 | requestId: ret.requestId,
|
869 | requestStatus: 'fulfilled',
|
870 | },
|
871 | payload: 'hooray!',
|
872 | type: 'test/fulfilled',
|
873 | })
|
874 | })
|
875 |
|
876 | test('rejectedMeta', async () => {
|
877 | const fulfilledThunk = createAsyncThunk<
|
878 | string,
|
879 | string,
|
880 | { rejectedMeta: { extraProp: string } }
|
881 | >('test', (arg: string, { rejectWithValue }) => {
|
882 | return rejectWithValue('damn!', { extraProp: 'baz' })
|
883 | })
|
884 | const promise = store.dispatch(fulfilledThunk('testArg'))
|
885 | const ret = await promise
|
886 | expect(ret).toEqual({
|
887 | meta: {
|
888 | arg: 'testArg',
|
889 | extraProp: 'baz',
|
890 | requestId: promise.requestId,
|
891 | requestStatus: 'rejected',
|
892 | rejectedWithValue: true,
|
893 | aborted: false,
|
894 | condition: false,
|
895 | },
|
896 | error: { message: 'Rejected' },
|
897 | payload: 'damn!',
|
898 | type: 'test/rejected',
|
899 | })
|
900 |
|
901 | if (ret.meta.requestStatus === 'rejected' && ret.meta.rejectedWithValue) {
|
902 | expectType<string>(ret.meta.extraProp)
|
903 | } else {
|
904 |
|
905 |
|
906 | ret.meta.extraProp
|
907 | }
|
908 | })
|
909 | })
|
910 |
|
\ | No newline at end of file |