import { createApi } from '@reduxjs/toolkit/query/react' import { createAction } from '@reduxjs/toolkit' import { actionsReducer, hookWaitFor, setupApiStore, } from '../../tests/utils/helpers' import { renderHook, act, waitFor } from '@testing-library/react' import { delay } from 'msw' interface Post { id: string title: string contents: string } const baseQuery = vi.fn() beforeEach(() => baseQuery.mockReset()) const postAddedAction = createAction('postAdded') const api = createApi({ baseQuery: (...args: any[]) => { const result = baseQuery(...args) if (typeof result === 'object' && 'then' in result) return result .then((data: any) => ({ data, meta: 'meta' })) .catch((e: any) => ({ error: e })) return { data: result, meta: 'meta' } }, tagTypes: ['Post'], endpoints: (build) => ({ getPosts: build.query({ query: () => '/posts', }), post: build.query({ query: (id) => `post/${id}`, providesTags: ['Post'], }), updatePost: build.mutation & Partial>({ query: ({ id, ...patch }) => ({ url: `post/${id}`, method: 'PATCH', body: patch, }), async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) { const currentItem = api.endpoints.post.select(arg.id)(getState()) if (currentItem?.data) { dispatch( api.util.upsertQueryData('post', arg.id, { ...currentItem.data, ...arg, }), ) } }, invalidatesTags: (result) => (result ? ['Post'] : []), }), post2: build.query({ queryFn: async (id) => { await delay(20) return { data: { id, title: 'All about cheese.', contents: 'TODO', }, } }, }), postWithSideEffect: build.query({ query: (id) => `post/${id}`, providesTags: ['Post'], async onCacheEntryAdded(arg, api) { // Verify that lifecycle promise resolution works const res = await api.cacheDataLoaded // and leave a side effect we can check in the test api.dispatch(postAddedAction(res.data.id)) }, keepUnusedDataFor: 0.01, }), }), }) const storeRef = setupApiStore(api, { ...actionsReducer, }) describe('basic lifecycle', () => { let onStart = vi.fn(), onError = vi.fn(), onSuccess = vi.fn() const extendedApi = api.injectEndpoints({ endpoints: (build) => ({ test: build.mutation({ query: (x) => x, async onQueryStarted(arg, api) { onStart(arg) try { const result = await api.queryFulfilled onSuccess(result) } catch (e) { onError(e) } }, }), }), overrideExisting: true, }) beforeEach(() => { onStart.mockReset() onError.mockReset() onSuccess.mockReset() }) test('Does basic inserts and upserts', async () => { const newPost: Post = { id: '3', contents: 'Inserted content', title: 'Inserted title', } const insertPromise = storeRef.store.dispatch( api.util.upsertQueryData('post', newPost.id, newPost), ) await insertPromise const selectPost3 = api.endpoints.post.select(newPost.id) const insertedPostEntry = selectPost3(storeRef.store.getState()) expect(insertedPostEntry.isSuccess).toBe(true) expect(insertedPostEntry.data).toEqual(newPost) const updatedPost: Post = { id: '3', contents: 'Updated content', title: 'Updated title', } const updatePromise = storeRef.store.dispatch( api.util.upsertQueryData('post', updatedPost.id, updatedPost), ) await updatePromise const updatedPostEntry = selectPost3(storeRef.store.getState()) expect(updatedPostEntry.isSuccess).toBe(true) expect(updatedPostEntry.data).toEqual(updatedPost) }) test('success', async () => { const { result } = renderHook( () => extendedApi.endpoints.test.useMutation(), { wrapper: storeRef.wrapper, }, ) baseQuery.mockResolvedValue('success') expect(onStart).not.toHaveBeenCalled() expect(baseQuery).not.toHaveBeenCalled() act(() => void result.current[0]('arg')) expect(onStart).toHaveBeenCalledWith('arg') expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined) expect(onError).not.toHaveBeenCalled() expect(onSuccess).not.toHaveBeenCalled() await act(() => delay(5)) expect(onError).not.toHaveBeenCalled() expect(onSuccess).toHaveBeenCalledWith({ data: 'success', meta: 'meta' }) }) test('error', async () => { const { result } = renderHook( () => extendedApi.endpoints.test.useMutation(), { wrapper: storeRef.wrapper, }, ) baseQuery.mockRejectedValueOnce('error') expect(onStart).not.toHaveBeenCalled() expect(baseQuery).not.toHaveBeenCalled() act(() => void result.current[0]('arg')) expect(onStart).toHaveBeenCalledWith('arg') expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined) expect(onError).not.toHaveBeenCalled() expect(onSuccess).not.toHaveBeenCalled() await act(() => delay(5)) expect(onError).toHaveBeenCalledWith({ error: 'error', isUnhandledError: false, meta: undefined, }) expect(onSuccess).not.toHaveBeenCalled() }) }) describe('upsertQueryData', () => { test('inserts cache entry', async () => { baseQuery .mockResolvedValueOnce({ id: '3', title: 'All about cheese.', contents: 'TODO', }) // TODO I have no idea why the query is getting called multiple times, // but passing an additional mocked value (_any_ value) // seems to silence some annoying "got an undefined result" logging .mockResolvedValueOnce(42) const { result } = renderHook(() => api.endpoints.post.useQuery('3'), { wrapper: storeRef.wrapper, }) await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy()) const dataBefore = result.current.data expect(dataBefore).toEqual({ id: '3', title: 'All about cheese.', contents: 'TODO', }) await act(async () => { storeRef.store.dispatch( api.util.upsertQueryData('post', '3', { id: '3', title: 'All about cheese.', contents: 'I love cheese!', }), ) }) expect(result.current.data).not.toBe(dataBefore) expect(result.current.data).toEqual({ id: '3', title: 'All about cheese.', contents: 'I love cheese!', }) }) test('does update non-existing values', async () => { baseQuery // throw an error to make sure there is no cached data .mockImplementationOnce(async () => { throw new Error('failed to load') }) .mockResolvedValueOnce(42) // a subscriber is needed to have the data stay in the cache // Not sure if this is the wanted behavior, I would have liked // it to stay in the cache for the x amount of time the cache // is preserved normally after the last subscriber was unmounted const { result, rerender } = renderHook( () => api.endpoints.post.useQuery('4'), { wrapper: storeRef.wrapper, }, ) await hookWaitFor(() => expect(result.current.isError).toBeTruthy()) // upsert the data act(() => { storeRef.store.dispatch( api.util.upsertQueryData('post', '4', { id: '4', title: 'All about cheese', contents: 'I love cheese!', }), ) }) // rerender the hook rerender() // wait until everything has settled await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy()) // the cached data is returned as the result expect(result.current.data).toStrictEqual({ id: '4', title: 'All about cheese', contents: 'I love cheese!', }) }) test('upsert while a normal query is running (success)', async () => { const fetchedData = { id: '3', title: 'All about cheese.', contents: 'Yummy', } baseQuery.mockImplementation(() => delay(20).then(() => fetchedData)) const upsertedData = { id: '3', title: 'Data from a SSR Render', contents: 'This is just some random data', } const selector = api.endpoints.post.select('3') const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3')) const upsertRes = storeRef.store.dispatch( api.util.upsertQueryData('post', '3', upsertedData), ) await upsertRes let state = selector(storeRef.store.getState()) expect(state.data).toEqual(upsertedData) await fetchRes state = selector(storeRef.store.getState()) expect(state.data).toEqual(fetchedData) }) test('upsert while a normal query is running (rejected)', async () => { baseQuery.mockImplementationOnce(async () => { await delay(20) // eslint-disable-next-line no-throw-literal throw 'Error!' }) const upsertedData = { id: '3', title: 'Data from a SSR Render', contents: 'This is just some random data', } const selector = api.endpoints.post.select('3') const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3')) const upsertRes = storeRef.store.dispatch( api.util.upsertQueryData('post', '3', upsertedData), ) await upsertRes let state = selector(storeRef.store.getState()) expect(state.data).toEqual(upsertedData) expect(state.isSuccess).toBeTruthy() await fetchRes state = selector(storeRef.store.getState()) expect(state.data).toEqual(upsertedData) expect(state.isError).toBeTruthy() }) }) describe('upsertQueryEntries', () => { const posts: Post[] = [ { id: '1', contents: 'A', title: 'A', }, { id: '2', contents: 'B', title: 'B', }, { id: '3', contents: 'C', title: 'C', }, ] const entriesAction = api.util.upsertQueryEntries([ { endpointName: 'getPosts', arg: undefined, value: posts, }, ...posts.map((post) => ({ endpointName: 'postWithSideEffect' as const, arg: post.id, value: post, })), ]) test('Upserts many entries at once', async () => { storeRef.store.dispatch(entriesAction) const state = storeRef.store.getState() expect(api.endpoints.getPosts.select()(state).data).toBe(posts) expect(api.endpoints.postWithSideEffect.select('1')(state).data).toBe( posts[0], ) expect(api.endpoints.postWithSideEffect.select('2')(state).data).toBe( posts[1], ) expect(api.endpoints.postWithSideEffect.select('3')(state).data).toBe( posts[2], ) }) test('Triggers cache lifecycles and side effects', async () => { storeRef.store.dispatch(entriesAction) // Tricky timing. The cache data promises will be resolved // in microtasks. We need to wait for them. Best to do this // in a loop just to avoid a hardcoded delay, but also this // needs to complete before `keepUnusedDataFor` expires them. await waitFor( () => { const state = storeRef.store.getState() // onCacheEntryAdded should have run for each post, // including cache data being resolved for (const post of posts) { const matchingSideEffectAction = state.actions.find( (action) => postAddedAction.match(action) && action.payload === post.id, ) expect(matchingSideEffectAction).toBeTruthy() } const selectedData = api.endpoints.postWithSideEffect.select('1')(state).data expect(selectedData).toBe(posts[0]) }, { timeout: 50, interval: 5 }, ) // The cache data should be removed after the keepUnusedDataFor time, // so wait longer than that await delay(100) const stateAfter = storeRef.store.getState() expect(api.endpoints.postWithSideEffect.select('1')(stateAfter).data).toBe( undefined, ) }) }) describe('full integration', () => { test('success case', async () => { baseQuery .mockResolvedValueOnce({ id: '3', title: 'All about cheese.', contents: 'TODO', }) .mockResolvedValueOnce({ id: '3', title: 'Meanwhile, this changed server-side.', contents: 'Delicious cheese!', }) .mockResolvedValueOnce({ id: '3', title: 'Meanwhile, this changed server-side.', contents: 'Delicious cheese!', }) .mockResolvedValueOnce(42) const { result } = renderHook( () => ({ query: api.endpoints.post.useQuery('3'), mutation: api.endpoints.updatePost.useMutation(), }), { wrapper: storeRef.wrapper, }, ) await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy()) expect(result.current.query.data).toEqual({ id: '3', title: 'All about cheese.', contents: 'TODO', }) await act(async () => { await result.current.mutation[0]({ id: '3', contents: 'Delicious cheese!', }) }) expect(result.current.query.data).toEqual({ id: '3', title: 'Meanwhile, this changed server-side.', contents: 'Delicious cheese!', }) await hookWaitFor(() => expect(result.current.query.data).toEqual({ id: '3', title: 'Meanwhile, this changed server-side.', contents: 'Delicious cheese!', }), ) }) test('error case', async () => { baseQuery .mockResolvedValueOnce({ id: '3', title: 'All about cheese.', contents: 'TODO', }) .mockRejectedValueOnce('some error!') .mockResolvedValueOnce({ id: '3', title: 'Meanwhile, this changed server-side.', contents: 'TODO', }) .mockResolvedValueOnce(42) const { result } = renderHook( () => ({ query: api.endpoints.post.useQuery('3'), mutation: api.endpoints.updatePost.useMutation(), }), { wrapper: storeRef.wrapper, }, ) await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy()) expect(result.current.query.data).toEqual({ id: '3', title: 'All about cheese.', contents: 'TODO', }) await act(async () => { await result.current.mutation[0]({ id: '3', contents: 'Delicious cheese!', }) }) // optimistic update expect(result.current.query.data).toEqual({ id: '3', title: 'All about cheese.', contents: 'Delicious cheese!', }) // mutation failed - will not invalidate query and not refetch data from the server await expect(() => hookWaitFor( () => expect(result.current.query.data).toEqual({ id: '3', title: 'Meanwhile, this changed server-side.', contents: 'TODO', }), 50, ), ).rejects.toBeTruthy() act(() => void result.current.query.refetch()) // manually refetching gives up-to-date data await hookWaitFor( () => expect(result.current.query.data).toEqual({ id: '3', title: 'Meanwhile, this changed server-side.', contents: 'TODO', }), 50, ) }) test('Interop with in-flight requests', async () => { await act(async () => { const fetchRes = storeRef.store.dispatch( api.endpoints.post2.initiate('3'), ) const upsertRes = storeRef.store.dispatch( api.util.upsertQueryData('post2', '3', { id: '3', title: 'Upserted title', contents: 'Upserted contents', }), ) const selectEntry = api.endpoints.post2.select('3') await waitFor( () => { const entry1 = selectEntry(storeRef.store.getState()) expect(entry1.data).toEqual({ id: '3', title: 'Upserted title', contents: 'Upserted contents', }) }, { interval: 1, timeout: 15 }, ) await waitFor( () => { const entry2 = selectEntry(storeRef.store.getState()) expect(entry2.data).toEqual({ id: '3', title: 'All about cheese.', contents: 'TODO', }) }, { interval: 1 }, ) }) }) })