477 lines
13 KiB
Plaintext
477 lines
13 KiB
Plaintext
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,
|
|
)
|
|
})
|
|
})
|