UNPKG

28.5 kBPlain TextView Raw
1import type { AnyAction } from '@reduxjs/toolkit'
2import {
3 createAsyncThunk,
4 unwrapResult,
5 configureStore,
6 createReducer,
7} from '@reduxjs/toolkit'
8import { miniSerializeError } from '@internal/createAsyncThunk'
9
10import {
11 mockConsole,
12 createConsole,
13 getLog,
14} from 'console-testing-library/pure'
15import { expectType } from './helpers'
16
17declare global {
18 interface Window {
19 AbortController: AbortController
20 }
21}
22
23describe('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 // reset from however many times the store called it
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 // Have to check the bits of the action separately since the error was processed
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 // Have to check the bits of the action separately since the error was processed
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 // Have to check the bits of the action separately since the error was processed
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 // Have to check the bits of the action separately since the error was processed
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 // Have to check the bits of the action separately since the error was processed
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
356describe('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 // abortedAction with reason is dispatched after test/pending is dispatched
414 expect(store.getState()).toMatchObject([
415 {},
416 { type: 'test/pending' },
417 expectedAbortedAction,
418 ])
419
420 // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
421 expect(result).toMatchObject(expectedAbortedAction)
422
423 // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
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 // abortedAction with reason is dispatched after test/pending is dispatched
448 expect(store.getState()).toEqual([
449 expect.any(Object),
450 expect.objectContaining({ type: 'unaware/pending' }),
451 expect.objectContaining(expectedAbortedAction),
452 ])
453
454 // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
455 expect(result).toMatchObject(expectedAbortedAction)
456
457 // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
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 // should only log once, even if called twice
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
526test('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
539describe('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 // Override TS's expectation that this is a boolean
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})
679describe('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
731describe('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 // dispatching the thunks should be using the custom id generator
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 // dispatching the default options thunk should still generate an id,
780 // but not using the custom id generator
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
793test('`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
821describe('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 // could be caused by a `throw`, `abort()` or `condition` - no `rejectedMeta` in that case
905 // @ts-expect-error
906 ret.meta.extraProp
907 }
908 })
909})
910
\No newline at end of file