gs-port / node_modules /@reduxjs /toolkit /src /query /tests /optimisticUpdates.test.tsx
Scribbler310's picture
feat: enhance dashboard
c2b7eb3 verified
import { createApi } from '@reduxjs/toolkit/query/react'
import { act, renderHook } from '@testing-library/react'
import { delay } from 'msw'
import {
actionsReducer,
hookWaitFor,
setupApiStore,
} from '../../tests/utils/helpers'
import type { InvalidationState } from '../core/apiState'
interface Post {
id: string
title: string
contents: string
}
const baseQuery = vi.fn()
beforeEach(() => {
baseQuery.mockReset()
})
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) => ({
post: build.query<Post, string>({
query: (id) => `post/${id}`,
providesTags: ['Post'],
}),
listPosts: build.query<Post[], void>({
query: () => `posts`,
providesTags: (result) => [
...(result?.map(({ id }) => ({ type: 'Post' as const, id })) ?? []),
'Post',
],
}),
updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
query: ({ id, ...patch }) => ({
url: `post/${id}`,
method: 'PATCH',
body: patch,
}),
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
const { undo } = dispatch(
api.util.updateQueryData('post', id, (draft) => {
Object.assign(draft, patch)
}),
)
queryFulfilled.catch(undo)
},
invalidatesTags: (result) => (result ? ['Post'] : []),
}),
}),
})
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('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('updateQueryData', () => {
test('updates cache values, can apply inverse patch', 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',
})
let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
act(() => {
returnValue = storeRef.store.dispatch(
api.util.updateQueryData('post', '3', (draft) => {
draft.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!',
})
expect(returnValue).toEqual({
inversePatches: [{ op: 'replace', path: ['contents'], value: 'TODO' }],
patches: [{ op: 'replace', path: ['contents'], value: 'I love cheese!' }],
undo: expect.any(Function),
})
act(() => {
storeRef.store.dispatch(
api.util.patchQueryData('post', '3', returnValue.inversePatches),
)
})
expect(result.current.data).toEqual(dataBefore)
})
test('updates (list) cache values including provided tags, undos that', async () => {
baseQuery
.mockResolvedValueOnce([
{ id: '3', title: 'All about cheese.', contents: 'TODO' },
])
.mockResolvedValueOnce(42)
const { result } = renderHook(() => api.endpoints.listPosts.useQuery(), {
wrapper: storeRef.wrapper,
})
await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
let provided!: InvalidationState<'Post'>
act(() => {
provided = storeRef.store.getState().api.provided
})
const provided3 = provided.tags.Post['3']
let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
act(() => {
returnValue = storeRef.store.dispatch(
api.util.updateQueryData(
'listPosts',
undefined,
(draft) => {
draft.push({
id: '4',
title: 'Mostly about cheese.',
contents: 'TODO',
})
},
true,
),
)
})
act(() => {
provided = storeRef.store.getState().api.provided
})
const provided4 = provided.tags.Post['4']
expect(provided4).toEqual(provided3)
act(() => {
returnValue.undo()
})
act(() => {
provided = storeRef.store.getState().api.provided
})
const provided4Next = provided.tags.Post['4']
expect(provided4Next).toEqual([])
})
test('updates (list) cache values excluding provided tags, undoes that', async () => {
baseQuery
.mockResolvedValueOnce([
{ id: '3', title: 'All about cheese.', contents: 'TODO' },
])
.mockResolvedValueOnce(42)
const { result } = renderHook(() => api.endpoints.listPosts.useQuery(), {
wrapper: storeRef.wrapper,
})
await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
let provided!: InvalidationState<'Post'>
act(() => {
provided = storeRef.store.getState().api.provided
})
let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
act(() => {
returnValue = storeRef.store.dispatch(
api.util.updateQueryData(
'listPosts',
undefined,
(draft) => {
draft.push({
id: '4',
title: 'Mostly about cheese.',
contents: 'TODO',
})
},
false,
),
)
})
act(() => {
provided = storeRef.store.getState().api.provided
})
const provided4 = provided.tags.Post['4']
expect(provided4).toEqual(undefined)
act(() => {
returnValue.undo()
})
act(() => {
provided = storeRef.store.getState().api.provided
})
const provided4Next = provided.tags.Post['4']
expect(provided4Next).toEqual(undefined)
})
test('does not update non-existing values', async () => {
baseQuery
.mockImplementationOnce(async () => ({
id: '3',
title: 'All about cheese.',
contents: 'TODO',
}))
.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',
})
let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
act(() => {
returnValue = storeRef.store.dispatch(
api.util.updateQueryData('post', '4', (draft) => {
draft.contents = 'I love cheese!'
}),
)
})
expect(result.current.data).toBe(dataBefore)
expect(returnValue).toEqual({
inversePatches: [],
patches: [],
undo: expect.any(Function),
})
})
})
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',
})
act(() => {
result.current.mutation[0]({ id: '3', contents: 'Delicious cheese!' })
})
expect(result.current.query.data).toEqual({
id: '3',
title: 'All about cheese.',
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',
})
act(() => {
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!',
})
// rollback
await hookWaitFor(() =>
expect(result.current.query.data).toEqual({
id: '3',
title: 'All about cheese.',
contents: 'TODO',
}),
)
// 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,
)
})
})