| 1 | import { createApi } from '@reduxjs/toolkit/query/react'
|
|---|
| 2 | import { act, renderHook } from '@testing-library/react'
|
|---|
| 3 | import { delay } from 'msw'
|
|---|
| 4 | import {
|
|---|
| 5 | actionsReducer,
|
|---|
| 6 | hookWaitFor,
|
|---|
| 7 | setupApiStore,
|
|---|
| 8 | } from '../../tests/utils/helpers'
|
|---|
| 9 | import type { InvalidationState } from '../core/apiState'
|
|---|
| 10 |
|
|---|
| 11 | interface Post {
|
|---|
| 12 | id: string
|
|---|
| 13 | title: string
|
|---|
| 14 | contents: string
|
|---|
| 15 | }
|
|---|
| 16 |
|
|---|
| 17 | const baseQuery = vi.fn()
|
|---|
| 18 | beforeEach(() => {
|
|---|
| 19 | baseQuery.mockReset()
|
|---|
| 20 | })
|
|---|
| 21 |
|
|---|
| 22 | const api = createApi({
|
|---|
| 23 | baseQuery: (...args: any[]) => {
|
|---|
| 24 | const result = baseQuery(...args)
|
|---|
| 25 | if (typeof result === 'object' && 'then' in result)
|
|---|
| 26 | return result
|
|---|
| 27 | .then((data: any) => ({ data, meta: 'meta' }))
|
|---|
| 28 | .catch((e: any) => ({ error: e }))
|
|---|
| 29 | return { data: result, meta: 'meta' }
|
|---|
| 30 | },
|
|---|
| 31 | tagTypes: ['Post'],
|
|---|
| 32 | endpoints: (build) => ({
|
|---|
| 33 | post: build.query<Post, string>({
|
|---|
| 34 | query: (id) => `post/${id}`,
|
|---|
| 35 | providesTags: ['Post'],
|
|---|
| 36 | }),
|
|---|
| 37 | listPosts: build.query<Post[], void>({
|
|---|
| 38 | query: () => `posts`,
|
|---|
| 39 | providesTags: (result) => [
|
|---|
| 40 | ...(result?.map(({ id }) => ({ type: 'Post' as const, id })) ?? []),
|
|---|
| 41 | 'Post',
|
|---|
| 42 | ],
|
|---|
| 43 | }),
|
|---|
| 44 | updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
|
|---|
| 45 | query: ({ id, ...patch }) => ({
|
|---|
| 46 | url: `post/${id}`,
|
|---|
| 47 | method: 'PATCH',
|
|---|
| 48 | body: patch,
|
|---|
| 49 | }),
|
|---|
| 50 | async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
|
|---|
| 51 | const { undo } = dispatch(
|
|---|
| 52 | api.util.updateQueryData('post', id, (draft) => {
|
|---|
| 53 | Object.assign(draft, patch)
|
|---|
| 54 | }),
|
|---|
| 55 | )
|
|---|
| 56 | queryFulfilled.catch(undo)
|
|---|
| 57 | },
|
|---|
| 58 | invalidatesTags: (result) => (result ? ['Post'] : []),
|
|---|
| 59 | }),
|
|---|
| 60 | }),
|
|---|
| 61 | })
|
|---|
| 62 |
|
|---|
| 63 | const storeRef = setupApiStore(api, { ...actionsReducer })
|
|---|
| 64 |
|
|---|
| 65 | describe('basic lifecycle', () => {
|
|---|
| 66 | let onStart = vi.fn(),
|
|---|
| 67 | onError = vi.fn(),
|
|---|
| 68 | onSuccess = vi.fn()
|
|---|
| 69 |
|
|---|
| 70 | const extendedApi = api.injectEndpoints({
|
|---|
| 71 | endpoints: (build) => ({
|
|---|
| 72 | test: build.mutation({
|
|---|
| 73 | query: (x) => x,
|
|---|
| 74 | async onQueryStarted(arg, api) {
|
|---|
| 75 | onStart(arg)
|
|---|
| 76 | try {
|
|---|
| 77 | const result = await api.queryFulfilled
|
|---|
| 78 | onSuccess(result)
|
|---|
| 79 | } catch (e) {
|
|---|
| 80 | onError(e)
|
|---|
| 81 | }
|
|---|
| 82 | },
|
|---|
| 83 | }),
|
|---|
| 84 | }),
|
|---|
| 85 | overrideExisting: true,
|
|---|
| 86 | })
|
|---|
| 87 |
|
|---|
| 88 | beforeEach(() => {
|
|---|
| 89 | onStart.mockReset()
|
|---|
| 90 | onError.mockReset()
|
|---|
| 91 | onSuccess.mockReset()
|
|---|
| 92 | })
|
|---|
| 93 |
|
|---|
| 94 | test('success', async () => {
|
|---|
| 95 | const { result } = renderHook(
|
|---|
| 96 | () => extendedApi.endpoints.test.useMutation(),
|
|---|
| 97 | { wrapper: storeRef.wrapper },
|
|---|
| 98 | )
|
|---|
| 99 |
|
|---|
| 100 | baseQuery.mockResolvedValue('success')
|
|---|
| 101 |
|
|---|
| 102 | expect(onStart).not.toHaveBeenCalled()
|
|---|
| 103 | expect(baseQuery).not.toHaveBeenCalled()
|
|---|
| 104 | act(() => void result.current[0]('arg'))
|
|---|
| 105 | expect(onStart).toHaveBeenCalledWith('arg')
|
|---|
| 106 | expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)
|
|---|
| 107 |
|
|---|
| 108 | expect(onError).not.toHaveBeenCalled()
|
|---|
| 109 | expect(onSuccess).not.toHaveBeenCalled()
|
|---|
| 110 | await act(() => delay(5))
|
|---|
| 111 | expect(onError).not.toHaveBeenCalled()
|
|---|
| 112 | expect(onSuccess).toHaveBeenCalledWith({ data: 'success', meta: 'meta' })
|
|---|
| 113 | })
|
|---|
| 114 |
|
|---|
| 115 | test('error', async () => {
|
|---|
| 116 | const { result } = renderHook(
|
|---|
| 117 | () => extendedApi.endpoints.test.useMutation(),
|
|---|
| 118 | { wrapper: storeRef.wrapper },
|
|---|
| 119 | )
|
|---|
| 120 |
|
|---|
| 121 | baseQuery.mockRejectedValueOnce('error')
|
|---|
| 122 | expect(onStart).not.toHaveBeenCalled()
|
|---|
| 123 | expect(baseQuery).not.toHaveBeenCalled()
|
|---|
| 124 |
|
|---|
| 125 | act(() => void result.current[0]('arg'))
|
|---|
| 126 | expect(onStart).toHaveBeenCalledWith('arg')
|
|---|
| 127 | expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)
|
|---|
| 128 | expect(onError).not.toHaveBeenCalled()
|
|---|
| 129 | expect(onSuccess).not.toHaveBeenCalled()
|
|---|
| 130 | await act(() => delay(5))
|
|---|
| 131 | expect(onError).toHaveBeenCalledWith({
|
|---|
| 132 | error: 'error',
|
|---|
| 133 | isUnhandledError: false,
|
|---|
| 134 | meta: undefined,
|
|---|
| 135 | })
|
|---|
| 136 | expect(onSuccess).not.toHaveBeenCalled()
|
|---|
| 137 | })
|
|---|
| 138 | })
|
|---|
| 139 |
|
|---|
| 140 | describe('updateQueryData', () => {
|
|---|
| 141 | test('updates cache values, can apply inverse patch', async () => {
|
|---|
| 142 | baseQuery
|
|---|
| 143 | .mockResolvedValueOnce({
|
|---|
| 144 | id: '3',
|
|---|
| 145 | title: 'All about cheese.',
|
|---|
| 146 | contents: 'TODO',
|
|---|
| 147 | })
|
|---|
| 148 | // TODO I have no idea why the query is getting called multiple times,
|
|---|
| 149 | // but passing an additional mocked value (_any_ value)
|
|---|
| 150 | // seems to silence some annoying "got an undefined result" logging
|
|---|
| 151 | .mockResolvedValueOnce(42)
|
|---|
| 152 | const { result } = renderHook(() => api.endpoints.post.useQuery('3'), {
|
|---|
| 153 | wrapper: storeRef.wrapper,
|
|---|
| 154 | })
|
|---|
| 155 | await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
|
|---|
| 156 |
|
|---|
| 157 | const dataBefore = result.current.data
|
|---|
| 158 | expect(dataBefore).toEqual({
|
|---|
| 159 | id: '3',
|
|---|
| 160 | title: 'All about cheese.',
|
|---|
| 161 | contents: 'TODO',
|
|---|
| 162 | })
|
|---|
| 163 |
|
|---|
| 164 | let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
|
|---|
| 165 | act(() => {
|
|---|
| 166 | returnValue = storeRef.store.dispatch(
|
|---|
| 167 | api.util.updateQueryData('post', '3', (draft) => {
|
|---|
| 168 | draft.contents = 'I love cheese!'
|
|---|
| 169 | }),
|
|---|
| 170 | )
|
|---|
| 171 | })
|
|---|
| 172 |
|
|---|
| 173 | expect(result.current.data).not.toBe(dataBefore)
|
|---|
| 174 | expect(result.current.data).toEqual({
|
|---|
| 175 | id: '3',
|
|---|
| 176 | title: 'All about cheese.',
|
|---|
| 177 | contents: 'I love cheese!',
|
|---|
| 178 | })
|
|---|
| 179 |
|
|---|
| 180 | expect(returnValue).toEqual({
|
|---|
| 181 | inversePatches: [{ op: 'replace', path: ['contents'], value: 'TODO' }],
|
|---|
| 182 | patches: [{ op: 'replace', path: ['contents'], value: 'I love cheese!' }],
|
|---|
| 183 | undo: expect.any(Function),
|
|---|
| 184 | })
|
|---|
| 185 |
|
|---|
| 186 | act(() => {
|
|---|
| 187 | storeRef.store.dispatch(
|
|---|
| 188 | api.util.patchQueryData('post', '3', returnValue.inversePatches),
|
|---|
| 189 | )
|
|---|
| 190 | })
|
|---|
| 191 |
|
|---|
| 192 | expect(result.current.data).toEqual(dataBefore)
|
|---|
| 193 | })
|
|---|
| 194 |
|
|---|
| 195 | test('updates (list) cache values including provided tags, undos that', async () => {
|
|---|
| 196 | baseQuery
|
|---|
| 197 | .mockResolvedValueOnce([
|
|---|
| 198 | { id: '3', title: 'All about cheese.', contents: 'TODO' },
|
|---|
| 199 | ])
|
|---|
| 200 | .mockResolvedValueOnce(42)
|
|---|
| 201 | const { result } = renderHook(() => api.endpoints.listPosts.useQuery(), {
|
|---|
| 202 | wrapper: storeRef.wrapper,
|
|---|
| 203 | })
|
|---|
| 204 | await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
|
|---|
| 205 |
|
|---|
| 206 | let provided!: InvalidationState<'Post'>
|
|---|
| 207 | act(() => {
|
|---|
| 208 | provided = storeRef.store.getState().api.provided
|
|---|
| 209 | })
|
|---|
| 210 |
|
|---|
| 211 | const provided3 = provided.tags.Post['3']
|
|---|
| 212 |
|
|---|
| 213 | let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
|
|---|
| 214 | act(() => {
|
|---|
| 215 | returnValue = storeRef.store.dispatch(
|
|---|
| 216 | api.util.updateQueryData(
|
|---|
| 217 | 'listPosts',
|
|---|
| 218 | undefined,
|
|---|
| 219 | (draft) => {
|
|---|
| 220 | draft.push({
|
|---|
| 221 | id: '4',
|
|---|
| 222 | title: 'Mostly about cheese.',
|
|---|
| 223 | contents: 'TODO',
|
|---|
| 224 | })
|
|---|
| 225 | },
|
|---|
| 226 | true,
|
|---|
| 227 | ),
|
|---|
| 228 | )
|
|---|
| 229 | })
|
|---|
| 230 |
|
|---|
| 231 | act(() => {
|
|---|
| 232 | provided = storeRef.store.getState().api.provided
|
|---|
| 233 | })
|
|---|
| 234 |
|
|---|
| 235 | const provided4 = provided.tags.Post['4']
|
|---|
| 236 |
|
|---|
| 237 | expect(provided4).toEqual(provided3)
|
|---|
| 238 |
|
|---|
| 239 | act(() => {
|
|---|
| 240 | returnValue.undo()
|
|---|
| 241 | })
|
|---|
| 242 |
|
|---|
| 243 | act(() => {
|
|---|
| 244 | provided = storeRef.store.getState().api.provided
|
|---|
| 245 | })
|
|---|
| 246 |
|
|---|
| 247 | const provided4Next = provided.tags.Post['4']
|
|---|
| 248 |
|
|---|
| 249 | expect(provided4Next).toEqual([])
|
|---|
| 250 | })
|
|---|
| 251 |
|
|---|
| 252 | test('updates (list) cache values excluding provided tags, undoes that', async () => {
|
|---|
| 253 | baseQuery
|
|---|
| 254 | .mockResolvedValueOnce([
|
|---|
| 255 | { id: '3', title: 'All about cheese.', contents: 'TODO' },
|
|---|
| 256 | ])
|
|---|
| 257 | .mockResolvedValueOnce(42)
|
|---|
| 258 | const { result } = renderHook(() => api.endpoints.listPosts.useQuery(), {
|
|---|
| 259 | wrapper: storeRef.wrapper,
|
|---|
| 260 | })
|
|---|
| 261 | await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
|
|---|
| 262 |
|
|---|
| 263 | let provided!: InvalidationState<'Post'>
|
|---|
| 264 | act(() => {
|
|---|
| 265 | provided = storeRef.store.getState().api.provided
|
|---|
| 266 | })
|
|---|
| 267 |
|
|---|
| 268 | let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
|
|---|
| 269 | act(() => {
|
|---|
| 270 | returnValue = storeRef.store.dispatch(
|
|---|
| 271 | api.util.updateQueryData(
|
|---|
| 272 | 'listPosts',
|
|---|
| 273 | undefined,
|
|---|
| 274 | (draft) => {
|
|---|
| 275 | draft.push({
|
|---|
| 276 | id: '4',
|
|---|
| 277 | title: 'Mostly about cheese.',
|
|---|
| 278 | contents: 'TODO',
|
|---|
| 279 | })
|
|---|
| 280 | },
|
|---|
| 281 | false,
|
|---|
| 282 | ),
|
|---|
| 283 | )
|
|---|
| 284 | })
|
|---|
| 285 |
|
|---|
| 286 | act(() => {
|
|---|
| 287 | provided = storeRef.store.getState().api.provided
|
|---|
| 288 | })
|
|---|
| 289 |
|
|---|
| 290 | const provided4 = provided.tags.Post['4']
|
|---|
| 291 |
|
|---|
| 292 | expect(provided4).toEqual(undefined)
|
|---|
| 293 |
|
|---|
| 294 | act(() => {
|
|---|
| 295 | returnValue.undo()
|
|---|
| 296 | })
|
|---|
| 297 |
|
|---|
| 298 | act(() => {
|
|---|
| 299 | provided = storeRef.store.getState().api.provided
|
|---|
| 300 | })
|
|---|
| 301 |
|
|---|
| 302 | const provided4Next = provided.tags.Post['4']
|
|---|
| 303 |
|
|---|
| 304 | expect(provided4Next).toEqual(undefined)
|
|---|
| 305 | })
|
|---|
| 306 |
|
|---|
| 307 | test('does not update non-existing values', async () => {
|
|---|
| 308 | baseQuery
|
|---|
| 309 | .mockImplementationOnce(async () => ({
|
|---|
| 310 | id: '3',
|
|---|
| 311 | title: 'All about cheese.',
|
|---|
| 312 | contents: 'TODO',
|
|---|
| 313 | }))
|
|---|
| 314 | .mockResolvedValueOnce(42)
|
|---|
| 315 |
|
|---|
| 316 | const { result } = renderHook(() => api.endpoints.post.useQuery('3'), {
|
|---|
| 317 | wrapper: storeRef.wrapper,
|
|---|
| 318 | })
|
|---|
| 319 | await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
|
|---|
| 320 |
|
|---|
| 321 | const dataBefore = result.current.data
|
|---|
| 322 | expect(dataBefore).toEqual({
|
|---|
| 323 | id: '3',
|
|---|
| 324 | title: 'All about cheese.',
|
|---|
| 325 | contents: 'TODO',
|
|---|
| 326 | })
|
|---|
| 327 |
|
|---|
| 328 | let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
|
|---|
| 329 | act(() => {
|
|---|
| 330 | returnValue = storeRef.store.dispatch(
|
|---|
| 331 | api.util.updateQueryData('post', '4', (draft) => {
|
|---|
| 332 | draft.contents = 'I love cheese!'
|
|---|
| 333 | }),
|
|---|
| 334 | )
|
|---|
| 335 | })
|
|---|
| 336 |
|
|---|
| 337 | expect(result.current.data).toBe(dataBefore)
|
|---|
| 338 |
|
|---|
| 339 | expect(returnValue).toEqual({
|
|---|
| 340 | inversePatches: [],
|
|---|
| 341 | patches: [],
|
|---|
| 342 | undo: expect.any(Function),
|
|---|
| 343 | })
|
|---|
| 344 | })
|
|---|
| 345 | })
|
|---|
| 346 |
|
|---|
| 347 | describe('full integration', () => {
|
|---|
| 348 | test('success case', async () => {
|
|---|
| 349 | baseQuery
|
|---|
| 350 | .mockResolvedValueOnce({
|
|---|
| 351 | id: '3',
|
|---|
| 352 | title: 'All about cheese.',
|
|---|
| 353 | contents: 'TODO',
|
|---|
| 354 | })
|
|---|
| 355 | .mockResolvedValueOnce({
|
|---|
| 356 | id: '3',
|
|---|
| 357 | title: 'Meanwhile, this changed server-side.',
|
|---|
| 358 | contents: 'Delicious cheese!',
|
|---|
| 359 | })
|
|---|
| 360 | .mockResolvedValueOnce({
|
|---|
| 361 | id: '3',
|
|---|
| 362 | title: 'Meanwhile, this changed server-side.',
|
|---|
| 363 | contents: 'Delicious cheese!',
|
|---|
| 364 | })
|
|---|
| 365 | .mockResolvedValueOnce(42)
|
|---|
| 366 | const { result } = renderHook(
|
|---|
| 367 | () => ({
|
|---|
| 368 | query: api.endpoints.post.useQuery('3'),
|
|---|
| 369 | mutation: api.endpoints.updatePost.useMutation(),
|
|---|
| 370 | }),
|
|---|
| 371 | { wrapper: storeRef.wrapper },
|
|---|
| 372 | )
|
|---|
| 373 | await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())
|
|---|
| 374 |
|
|---|
| 375 | expect(result.current.query.data).toEqual({
|
|---|
| 376 | id: '3',
|
|---|
| 377 | title: 'All about cheese.',
|
|---|
| 378 | contents: 'TODO',
|
|---|
| 379 | })
|
|---|
| 380 |
|
|---|
| 381 | act(() => {
|
|---|
| 382 | result.current.mutation[0]({ id: '3', contents: 'Delicious cheese!' })
|
|---|
| 383 | })
|
|---|
| 384 |
|
|---|
| 385 | expect(result.current.query.data).toEqual({
|
|---|
| 386 | id: '3',
|
|---|
| 387 | title: 'All about cheese.',
|
|---|
| 388 | contents: 'Delicious cheese!',
|
|---|
| 389 | })
|
|---|
| 390 |
|
|---|
| 391 | await hookWaitFor(() =>
|
|---|
| 392 | expect(result.current.query.data).toEqual({
|
|---|
| 393 | id: '3',
|
|---|
| 394 | title: 'Meanwhile, this changed server-side.',
|
|---|
| 395 | contents: 'Delicious cheese!',
|
|---|
| 396 | }),
|
|---|
| 397 | )
|
|---|
| 398 | })
|
|---|
| 399 |
|
|---|
| 400 | test('error case', async () => {
|
|---|
| 401 | baseQuery
|
|---|
| 402 | .mockResolvedValueOnce({
|
|---|
| 403 | id: '3',
|
|---|
| 404 | title: 'All about cheese.',
|
|---|
| 405 | contents: 'TODO',
|
|---|
| 406 | })
|
|---|
| 407 | .mockRejectedValueOnce('some error!')
|
|---|
| 408 | .mockResolvedValueOnce({
|
|---|
| 409 | id: '3',
|
|---|
| 410 | title: 'Meanwhile, this changed server-side.',
|
|---|
| 411 | contents: 'TODO',
|
|---|
| 412 | })
|
|---|
| 413 | .mockResolvedValueOnce(42)
|
|---|
| 414 |
|
|---|
| 415 | const { result } = renderHook(
|
|---|
| 416 | () => ({
|
|---|
| 417 | query: api.endpoints.post.useQuery('3'),
|
|---|
| 418 | mutation: api.endpoints.updatePost.useMutation(),
|
|---|
| 419 | }),
|
|---|
| 420 | { wrapper: storeRef.wrapper },
|
|---|
| 421 | )
|
|---|
| 422 | await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())
|
|---|
| 423 |
|
|---|
| 424 | expect(result.current.query.data).toEqual({
|
|---|
| 425 | id: '3',
|
|---|
| 426 | title: 'All about cheese.',
|
|---|
| 427 | contents: 'TODO',
|
|---|
| 428 | })
|
|---|
| 429 |
|
|---|
| 430 | act(() => {
|
|---|
| 431 | result.current.mutation[0]({ id: '3', contents: 'Delicious cheese!' })
|
|---|
| 432 | })
|
|---|
| 433 |
|
|---|
| 434 | // optimistic update
|
|---|
| 435 | expect(result.current.query.data).toEqual({
|
|---|
| 436 | id: '3',
|
|---|
| 437 | title: 'All about cheese.',
|
|---|
| 438 | contents: 'Delicious cheese!',
|
|---|
| 439 | })
|
|---|
| 440 |
|
|---|
| 441 | // rollback
|
|---|
| 442 | await hookWaitFor(() =>
|
|---|
| 443 | expect(result.current.query.data).toEqual({
|
|---|
| 444 | id: '3',
|
|---|
| 445 | title: 'All about cheese.',
|
|---|
| 446 | contents: 'TODO',
|
|---|
| 447 | }),
|
|---|
| 448 | )
|
|---|
| 449 |
|
|---|
| 450 | // mutation failed - will not invalidate query and not refetch data from the server
|
|---|
| 451 | await expect(() =>
|
|---|
| 452 | hookWaitFor(
|
|---|
| 453 | () =>
|
|---|
| 454 | expect(result.current.query.data).toEqual({
|
|---|
| 455 | id: '3',
|
|---|
| 456 | title: 'Meanwhile, this changed server-side.',
|
|---|
| 457 | contents: 'TODO',
|
|---|
| 458 | }),
|
|---|
| 459 | 50,
|
|---|
| 460 | ),
|
|---|
| 461 | ).rejects.toBeTruthy()
|
|---|
| 462 |
|
|---|
| 463 | act(() => void result.current.query.refetch())
|
|---|
| 464 |
|
|---|
| 465 | // manually refetching gives up-to-date data
|
|---|
| 466 | await hookWaitFor(
|
|---|
| 467 | () =>
|
|---|
| 468 | expect(result.current.query.data).toEqual({
|
|---|
| 469 | id: '3',
|
|---|
| 470 | title: 'Meanwhile, this changed server-side.',
|
|---|
| 471 | contents: 'TODO',
|
|---|
| 472 | }),
|
|---|
| 473 | 50,
|
|---|
| 474 | )
|
|---|
| 475 | })
|
|---|
| 476 | })
|
|---|