UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

580 lines (526 loc) 19.2 kB
import { createApi } from '@reduxjs/toolkit/query' import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query' import { fetchBaseQuery } from '@reduxjs/toolkit/query' import { expectType, fakeTimerWaitFor, setupApiStore, waitMs } from './helpers' beforeAll(() => { jest.useFakeTimers() }) const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'http://example.com' }), endpoints: () => ({}), }) const storeRef = setupApiStore(api) const onNewCacheEntry = jest.fn() const gotFirstValue = jest.fn() const onCleanup = jest.fn() const onCatch = jest.fn() beforeEach(() => { onNewCacheEntry.mockClear() gotFirstValue.mockClear() onCleanup.mockClear() onCatch.mockClear() }) describe.each([['query'], ['mutation']] as const)( 'generic cases: %s', (type) => { test(`${type}: new cache entry only`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/success', onCacheEntryAdded(arg, { dispatch, getState }) { onNewCacheEntry(arg) }, }), }), }) storeRef.store.dispatch(extended.endpoints.injected.initiate('arg')) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') }) test(`${type}: await cacheEntryRemoved`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved } ) { onNewCacheEntry(arg) await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') expect(onCleanup).not.toHaveBeenCalled() promise.unsubscribe(), await waitMs() if (type === 'query') { jest.advanceTimersByTime(59000), await waitMs() expect(onCleanup).not.toHaveBeenCalled() jest.advanceTimersByTime(2000), await waitMs() } expect(onCleanup).toHaveBeenCalled() }) test(`${type}: await cacheDataLoaded, await cacheEntryRemoved (success)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<number, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } ) { onNewCacheEntry(arg) const firstValue = await cacheDataLoaded expectType<{ data: number; meta?: FetchBaseQueryMeta }>( firstValue ) gotFirstValue(firstValue) await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') expect(gotFirstValue).not.toHaveBeenCalled() expect(onCleanup).not.toHaveBeenCalled() await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) expect(gotFirstValue).toHaveBeenCalledWith({ data: { value: 'success' }, meta: { request: expect.any(Request), response: expect.any(Object), // Response is not available in jest env }, }) expect(onCleanup).not.toHaveBeenCalled() promise.unsubscribe(), await waitMs() if (type === 'query') { jest.advanceTimersByTime(59000), await waitMs() expect(onCleanup).not.toHaveBeenCalled() jest.advanceTimersByTime(2000), await waitMs() } expect(onCleanup).toHaveBeenCalled() }) test(`${type}: await cacheDataLoaded, await cacheEntryRemoved (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } ) { onNewCacheEntry(arg) // this will wait until cacheEntryRemoved, then reject => nothing past that line will execute // but since this special "cacheEntryRemoved" rejection is handled outside, there will be no // uncaught rejection error const firstValue = await cacheDataLoaded gotFirstValue(firstValue) await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') promise.unsubscribe(), await waitMs() if (type === 'query') { jest.advanceTimersByTime(120000), await waitMs() } expect(gotFirstValue).not.toHaveBeenCalled() expect(onCleanup).not.toHaveBeenCalled() }) test(`${type}: try { await cacheDataLoaded }, await cacheEntryRemoved (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } ) { onNewCacheEntry(arg) try { // this will wait until cacheEntryRemoved, then reject => nothing else in this try..catch block will execute const firstValue = await cacheDataLoaded gotFirstValue(firstValue) } catch (e) { onCatch(e) } await cacheEntryRemoved onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') promise.unsubscribe(), await waitMs() if (type === 'query') { jest.advanceTimersByTime(59000), await waitMs() expect(onCleanup).not.toHaveBeenCalled() jest.advanceTimersByTime(2000), await waitMs() } expect(onCleanup).toHaveBeenCalled() expect(gotFirstValue).not.toHaveBeenCalled() expect(onCatch.mock.calls[0][0]).toMatchObject({ message: 'Promise never resolved before cacheEntryRemoved.', }) }) test(`${type}: try { await cacheDataLoaded, await cacheEntryRemoved } (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } ) { onNewCacheEntry(arg) try { // this will wait until cacheEntryRemoved, then reject => nothing else in this try..catch block will execute const firstValue = await cacheDataLoaded gotFirstValue(firstValue) // cleanup in this scenario only needs to be done for stuff within this try..catch block - totally valid scenario await cacheEntryRemoved onCleanup() } catch (e) { onCatch(e) } }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') promise.unsubscribe(), await waitMs() if (type === 'query') { jest.advanceTimersByTime(59000), await waitMs() expect(onCleanup).not.toHaveBeenCalled() jest.advanceTimersByTime(2000), await waitMs() } expect(onCleanup).not.toHaveBeenCalled() expect(gotFirstValue).not.toHaveBeenCalled() expect(onCatch.mock.calls[0][0]).toMatchObject({ message: 'Promise never resolved before cacheEntryRemoved.', }) }) test(`${type}: try { await cacheDataLoaded } finally { await cacheEntryRemoved } (cacheDataLoaded never resolves)`, async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build[type as 'mutation']<unknown, string>({ query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } ) { onNewCacheEntry(arg) try { // this will wait until cacheEntryRemoved, then reject => nothing else in this try..catch block will execute const firstValue = await cacheDataLoaded gotFirstValue(firstValue) } catch (e) { onCatch(e) } finally { await cacheEntryRemoved onCleanup() } }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') promise.unsubscribe(), await waitMs() if (type === 'query') { jest.advanceTimersByTime(59000), await waitMs() expect(onCleanup).not.toHaveBeenCalled() jest.advanceTimersByTime(2000), await waitMs() } expect(onCleanup).toHaveBeenCalled() expect(gotFirstValue).not.toHaveBeenCalled() expect(onCatch.mock.calls[0][0]).toMatchObject({ message: 'Promise never resolved before cacheEntryRemoved.', }) }) } ) test(`query: getCacheEntry`, async () => { const snapshot = jest.fn() const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<unknown, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, getCacheEntry, cacheEntryRemoved, cacheDataLoaded, } ) { snapshot(getCacheEntry()) gotFirstValue(await cacheDataLoaded) snapshot(getCacheEntry()) await cacheEntryRemoved snapshot(getCacheEntry()) }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) promise.unsubscribe() await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) jest.advanceTimersByTime(120000), await waitMs() expect(snapshot).toHaveBeenCalledTimes(3) expect(snapshot.mock.calls[0][0]).toMatchObject({ endpointName: 'injected', isError: false, isLoading: true, isSuccess: false, isUninitialized: false, originalArgs: 'arg', requestId: promise.requestId, startedTimeStamp: expect.any(Number), status: 'pending', }) expect(snapshot.mock.calls[1][0]).toMatchObject({ data: { value: 'success', }, endpointName: 'injected', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, originalArgs: 'arg', requestId: promise.requestId, startedTimeStamp: expect.any(Number), status: 'fulfilled', }) expect(snapshot.mock.calls[2][0]).toMatchObject({ isError: false, isLoading: false, isSuccess: false, isUninitialized: true, status: 'uninitialized', }) }) test(`mutation: getCacheEntry`, async () => { const snapshot = jest.fn() const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.mutation<unknown, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, getCacheEntry, cacheEntryRemoved, cacheDataLoaded, } ) { snapshot(getCacheEntry()) gotFirstValue(await cacheDataLoaded) snapshot(getCacheEntry()) await cacheEntryRemoved snapshot(getCacheEntry()) }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) promise.unsubscribe(), await waitMs() expect(snapshot).toHaveBeenCalledTimes(3) expect(snapshot.mock.calls[0][0]).toMatchObject({ endpointName: 'injected', isError: false, isLoading: true, isSuccess: false, isUninitialized: false, startedTimeStamp: expect.any(Number), status: 'pending', }) expect(snapshot.mock.calls[1][0]).toMatchObject({ data: { value: 'success', }, endpointName: 'injected', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, startedTimeStamp: expect.any(Number), status: 'fulfilled', }) expect(snapshot.mock.calls[2][0]).toMatchObject({ isError: false, isLoading: false, isSuccess: false, isUninitialized: true, status: 'uninitialized', }) }) test('updateCachedData', async () => { const trackCalls = jest.fn() const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<{ value: string }, string>({ query: () => '/success', async onCacheEntryAdded( arg, { dispatch, getState, getCacheEntry, updateCachedData, cacheEntryRemoved, cacheDataLoaded, } ) { expect(getCacheEntry().data).toEqual(undefined) // calling `updateCachedData` when there is no data yet should not do anything updateCachedData((draft) => { draft.value = 'TEST' trackCalls() }) expect(trackCalls).toHaveBeenCalledTimes(0) expect(getCacheEntry().data).toEqual(undefined) gotFirstValue(await cacheDataLoaded) expect(getCacheEntry().data).toEqual({ value: 'success' }) updateCachedData((draft) => { draft.value = 'TEST' trackCalls() }) expect(trackCalls).toHaveBeenCalledTimes(1) expect(getCacheEntry().data).toEqual({ value: 'TEST' }) await cacheEntryRemoved expect(getCacheEntry().data).toEqual(undefined) // calling `updateCachedData` when there is no data any more should not do anything updateCachedData((draft) => { draft.value = 'TEST2' trackCalls() }) expect(trackCalls).toHaveBeenCalledTimes(1) expect(getCacheEntry().data).toEqual(undefined) onCleanup() }, }), }), }) const promise = storeRef.store.dispatch( extended.endpoints.injected.initiate('arg') ) promise.unsubscribe() await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() }) jest.advanceTimersByTime(61000) await fakeTimerWaitFor(() => { expect(onCleanup).toHaveBeenCalled() }) }) test('dispatching further actions does not trigger another lifecycle', async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<unknown, void>({ query: () => '/success', async onCacheEntryAdded() { onNewCacheEntry() }, }), }), }) await storeRef.store.dispatch(extended.endpoints.injected.initiate()) expect(onNewCacheEntry).toHaveBeenCalledTimes(1) await storeRef.store.dispatch(extended.endpoints.injected.initiate()) expect(onNewCacheEntry).toHaveBeenCalledTimes(1) await storeRef.store.dispatch( extended.endpoints.injected.initiate(undefined, { forceRefetch: true }) ) expect(onNewCacheEntry).toHaveBeenCalledTimes(1) }) test('dispatching a query initializer with `subscribe: false` does not start a lifecycle', async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.query<unknown, void>({ query: () => '/success', async onCacheEntryAdded() { onNewCacheEntry() }, }), }), }) await storeRef.store.dispatch( extended.endpoints.injected.initiate(undefined, { subscribe: false }) ) expect(onNewCacheEntry).toHaveBeenCalledTimes(0) await storeRef.store.dispatch(extended.endpoints.injected.initiate(undefined)) expect(onNewCacheEntry).toHaveBeenCalledTimes(1) }) test('dispatching a mutation initializer with `track: false` does not start a lifecycle', async () => { const extended = api.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ injected: build.mutation<unknown, void>({ query: () => '/success', async onCacheEntryAdded() { onNewCacheEntry() }, }), }), }) await storeRef.store.dispatch( extended.endpoints.injected.initiate(undefined, { track: false }) ) expect(onNewCacheEntry).toHaveBeenCalledTimes(0) await storeRef.store.dispatch(extended.endpoints.injected.initiate(undefined)) expect(onNewCacheEntry).toHaveBeenCalledTimes(1) })