UNPKG

@reduxjs/toolkit

Version:

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

1,628 lines (1,483 loc) 60.5 kB
import { noop } from '@internal/listenerMiddleware/utils' import { server } from '@internal/query/tests/mocks/server' import { getSerializedHeaders, setupApiStore, } from '@internal/tests/utils/helpers' import type { SerializedError } from '@reduxjs/toolkit' import { configureStore, createAction, createReducer } from '@reduxjs/toolkit' import type { DefinitionsFromApi, FetchBaseQueryError, FetchBaseQueryMeta, OverrideResultType, SchemaFailureConverter, SerializeQueryArgs, TagTypesFromApi, } from '@reduxjs/toolkit/query' import { createApi, fetchBaseQuery, NamedSchemaError, } from '@reduxjs/toolkit/query' import { HttpResponse, delay, http } from 'msw' import nodeFetch from 'node-fetch' import * as v from 'valibot' import type { SchemaFailureHandler } from '../endpointDefinitions' beforeAll(() => { vi.stubEnv('NODE_ENV', 'development') return vi.unstubAllEnvs }) const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(noop) afterEach(() => { vi.clearAllMocks() server.resetHandlers() }) afterAll(() => { vi.restoreAllMocks() }) function paginate<T>(array: T[], page_size: number, page_number: number) { // human-readable page numbers usually start with 1, so we reduce 1 in the first argument return array.slice((page_number - 1) * page_size, page_number * page_size) } test('sensible defaults', () => { const api = createApi({ baseQuery: fetchBaseQuery(), endpoints: (build) => ({ getUser: build.query<unknown, void>({ query(id) { return { url: `user/${id}` } }, }), updateUser: build.mutation<unknown, void>({ query: () => '', }), }), }) configureStore({ reducer: { [api.reducerPath]: api.reducer, }, middleware: (gDM) => gDM().concat(api.middleware), }) expect(api.reducerPath).toBe('api') expect(api.endpoints.getUser.name).toBe('getUser') expect(api.endpoints.updateUser.name).toBe('updateUser') }) describe('wrong tagTypes log errors', () => { const baseQuery = vi.fn() const api = createApi({ baseQuery, tagTypes: ['User'], endpoints: (build) => ({ provideNothing: build.query<unknown, void>({ query: () => '', }), provideTypeString: build.query<unknown, void>({ query: () => '', providesTags: ['User'], }), provideTypeWithId: build.query<unknown, void>({ query: () => '', providesTags: [{ type: 'User', id: 5 }], }), provideTypeWithIdAndCallback: build.query<unknown, void>({ query: () => '', providesTags: () => [{ type: 'User', id: 5 }], }), provideWrongTypeString: build.query<unknown, void>({ query: () => '', // @ts-expect-error providesTags: ['Users'], }), provideWrongTypeWithId: build.query<unknown, void>({ query: () => '', // @ts-expect-error providesTags: [{ type: 'Users', id: 5 }], }), provideWrongTypeWithIdAndCallback: build.query<unknown, void>({ query: () => '', // @ts-expect-error providesTags: () => [{ type: 'Users', id: 5 }], }), invalidateNothing: build.query<unknown, void>({ query: () => '', }), invalidateTypeString: build.mutation<unknown, void>({ query: () => '', invalidatesTags: ['User'], }), invalidateTypeWithId: build.mutation<unknown, void>({ query: () => '', invalidatesTags: [{ type: 'User', id: 5 }], }), invalidateTypeWithIdAndCallback: build.mutation<unknown, void>({ query: () => '', invalidatesTags: () => [{ type: 'User', id: 5 }], }), invalidateWrongTypeString: build.mutation<unknown, void>({ query: () => '', // @ts-expect-error invalidatesTags: ['Users'], }), invalidateWrongTypeWithId: build.mutation<unknown, void>({ query: () => '', // @ts-expect-error invalidatesTags: [{ type: 'Users', id: 5 }], }), invalidateWrongTypeWithIdAndCallback: build.mutation<unknown, void>({ query: () => '', // @ts-expect-error invalidatesTags: () => [{ type: 'Users', id: 5 }], }), }), }) const store = configureStore({ reducer: { [api.reducerPath]: api.reducer, }, middleware: (gDM) => gDM().concat(api.middleware), }) beforeEach(() => { baseQuery.mockResolvedValue({ data: 'foo' }) }) test.each<[keyof typeof api.endpoints, boolean?]>([ ['provideNothing', false], ['provideTypeString', false], ['provideTypeWithId', false], ['provideTypeWithIdAndCallback', false], ['provideWrongTypeString', true], ['provideWrongTypeWithId', true], ['provideWrongTypeWithIdAndCallback', true], ['invalidateNothing', false], ['invalidateTypeString', false], ['invalidateTypeWithId', false], ['invalidateTypeWithIdAndCallback', false], ['invalidateWrongTypeString', true], ['invalidateWrongTypeWithId', true], ['invalidateWrongTypeWithIdAndCallback', true], ])(`endpoint %s should log an error? %s`, async (endpoint, shouldError) => { vi.stubEnv('NODE_ENV', 'development') // @ts-ignore store.dispatch(api.endpoints[endpoint].initiate()) let result: { status: string } do { await delay(5) // @ts-ignore result = api.endpoints[endpoint].select()(store.getState()) } while (result.status === 'pending') if (shouldError) { expect(consoleErrorSpy).toHaveBeenLastCalledWith( "Tag type 'Users' was used, but not specified in `tagTypes`!", ) } else { expect(consoleErrorSpy).not.toHaveBeenCalled() } }) }) describe('endpoint definition typings', () => { const api = createApi({ baseQuery: (from: 'From'): { data: 'To' } | Promise<{ data: 'To' }> => ({ data: 'To', }), endpoints: () => ({}), tagTypes: ['typeA', 'typeB'], }) test('query: query & transformResponse types', () => { api.injectEndpoints({ endpoints: (build) => ({ query: build.query<'RetVal', 'Arg'>({ query: (x: 'Arg') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }), query1: build.query<'RetVal', 'Arg'>({ // @ts-expect-error query: (x: 'Error') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }), query2: build.query<'RetVal', 'Arg'>({ // @ts-expect-error query: (x: 'Arg') => 'Error' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }), query3: build.query<'RetVal', 'Arg'>({ query: (x: 'Arg') => 'From' as const, // @ts-expect-error transformResponse(r: 'Error') { return 'RetVal' as const }, }), query4: build.query<'RetVal', 'Arg'>({ query: (x: 'Arg') => 'From' as const, // @ts-expect-error transformResponse(r: 'To') { return 'Error' as const }, }), queryInference1: build.query<'RetVal', 'Arg'>({ query: (x) => { return 'From' }, transformResponse(r) { return 'RetVal' }, }), queryInference2: (() => { const query = build.query({ query: (x: 'Arg') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }) return query })(), }), }) }) test('mutation: query & transformResponse types', () => { api.injectEndpoints({ endpoints: (build) => ({ query: build.mutation<'RetVal', 'Arg'>({ query: (x: 'Arg') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }), query1: build.mutation<'RetVal', 'Arg'>({ // @ts-expect-error query: (x: 'Error') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }), query2: build.mutation<'RetVal', 'Arg'>({ // @ts-expect-error query: (x: 'Arg') => 'Error' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }), query3: build.mutation<'RetVal', 'Arg'>({ query: (x: 'Arg') => 'From' as const, // @ts-expect-error transformResponse(r: 'Error') { return 'RetVal' as const }, }), query4: build.mutation<'RetVal', 'Arg'>({ query: (x: 'Arg') => 'From' as const, // @ts-expect-error transformResponse(r: 'To') { return 'Error' as const }, }), mutationInference1: build.mutation<'RetVal', 'Arg'>({ query: (x) => { return 'From' }, transformResponse(r) { return 'RetVal' }, }), mutationInference2: (() => { const query = build.mutation({ query: (x: 'Arg') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }) return query })(), }), }) }) describe('enhancing endpoint definitions', () => { const baseQuery = vi.fn((x: string) => ({ data: 'success' })) const commonBaseQueryApi = { dispatch: expect.any(Function), endpoint: expect.any(String), abort: expect.any(Function), extra: undefined, forced: expect.any(Boolean), getState: expect.any(Function), signal: expect.any(Object), type: expect.any(String), queryCacheKey: expect.any(String), } beforeEach(() => { baseQuery.mockClear() }) function getNewApi() { return createApi({ baseQuery, tagTypes: ['old'], endpoints: (build) => ({ query1: build.query<'out1', 'in1'>({ query: (id) => `${id}` }), query2: build.query<'out2', 'in2'>({ query: (id) => `${id}` }), mutation1: build.mutation<'out1', 'in1'>({ query: (id) => `${id}` }), mutation2: build.mutation<'out2', 'in2'>({ query: (id) => `${id}` }), }), }) } let api = getNewApi() beforeEach(() => { api = getNewApi() }) test('pre-modification behavior', async () => { const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) storeRef.store.dispatch(api.endpoints.query1.initiate('in1')) storeRef.store.dispatch(api.endpoints.query2.initiate('in2')) storeRef.store.dispatch(api.endpoints.mutation1.initiate('in1')) storeRef.store.dispatch(api.endpoints.mutation2.initiate('in2')) expect(baseQuery.mock.calls).toEqual([ [ 'in1', { dispatch: expect.any(Function), endpoint: expect.any(String), getState: expect.any(Function), signal: expect.any(Object), abort: expect.any(Function), forced: expect.any(Boolean), type: expect.any(String), queryCacheKey: expect.any(String), }, undefined, ], [ 'in2', { dispatch: expect.any(Function), endpoint: expect.any(String), getState: expect.any(Function), signal: expect.any(Object), abort: expect.any(Function), forced: expect.any(Boolean), type: expect.any(String), queryCacheKey: expect.any(String), }, undefined, ], [ 'in1', { dispatch: expect.any(Function), endpoint: expect.any(String), getState: expect.any(Function), signal: expect.any(Object), abort: expect.any(Function), // forced: undefined, type: expect.any(String), }, undefined, ], [ 'in2', { dispatch: expect.any(Function), endpoint: expect.any(String), getState: expect.any(Function), signal: expect.any(Object), abort: expect.any(Function), // forced: undefined, type: expect.any(String), }, undefined, ], ]) }) test('warn on wrong tagType', async () => { vi.stubEnv('NODE_ENV', 'development') const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) // only type-test this part if (2 > 1) { api.enhanceEndpoints({ endpoints: { query1: { // @ts-expect-error providesTags: ['new'], }, query2: { // @ts-expect-error providesTags: ['missing'], }, }, }) } const enhanced = api.enhanceEndpoints({ addTagTypes: ['new'], endpoints: { query1: { providesTags: ['new'], }, query2: { // @ts-expect-error providesTags: ['missing'], }, }, }) storeRef.store.dispatch(api.endpoints.query1.initiate('in1')) await delay(1) expect(consoleErrorSpy).not.toHaveBeenCalled() storeRef.store.dispatch(api.endpoints.query2.initiate('in2')) await delay(1) expect(consoleErrorSpy).toHaveBeenCalledOnce() expect(consoleErrorSpy).toHaveBeenLastCalledWith( "Tag type 'missing' was used, but not specified in `tagTypes`!", ) // only type-test this part if (2 > 1) { enhanced.enhanceEndpoints({ endpoints: { query1: { // returned `enhanced` api contains "new" enitityType providesTags: ['new'], }, query2: { // @ts-expect-error providesTags: ['missing'], }, }, }) } }) test('modify', () => { const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) api.enhanceEndpoints({ endpoints: { query1: { query: (x) => { return 'modified1' }, }, query2(definition) { definition.query = (x) => { return 'modified2' } }, mutation1: { query: (x) => { return 'modified1' }, }, mutation2(definition) { definition.query = (x) => { return 'modified2' } }, // @ts-expect-error nonExisting: {}, }, }) storeRef.store.dispatch(api.endpoints.query1.initiate('in1')) storeRef.store.dispatch(api.endpoints.query2.initiate('in2')) storeRef.store.dispatch(api.endpoints.mutation1.initiate('in1')) storeRef.store.dispatch(api.endpoints.mutation2.initiate('in2')) expect(baseQuery.mock.calls).toEqual([ ['modified1', commonBaseQueryApi, undefined], ['modified2', commonBaseQueryApi, undefined], [ 'modified1', { ...commonBaseQueryApi, forced: undefined, queryCacheKey: undefined, }, undefined, ], [ 'modified2', { ...commonBaseQueryApi, forced: undefined, queryCacheKey: undefined, }, undefined, ], ]) }) test('updated transform response types', async () => { const baseApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), tagTypes: ['old'], endpoints: (build) => ({ query1: build.query<'out1', void>({ query: () => 'success' }), mutation1: build.mutation<'out1', void>({ query: () => 'success' }), }), }) type Transformed = { value: string } type Definitions = DefinitionsFromApi<typeof api> type TagTypes = TagTypesFromApi<typeof api> type Q1Definition = OverrideResultType<Definitions['query1'], Transformed> type M1Definition = OverrideResultType< Definitions['mutation1'], Transformed > type UpdatedDefitions = Omit<Definitions, 'query1' | 'mutation1'> & { query1: Q1Definition mutation1: M1Definition } const enhancedApi = baseApi.enhanceEndpoints<TagTypes, UpdatedDefitions>({ endpoints: { query1: { transformResponse: (a, b, c) => ({ value: 'transformed', }), }, mutation1: { transformResponse: (a, b, c) => ({ value: 'transformed', }), }, }, }) const storeRef = setupApiStore(enhancedApi, undefined, { withoutTestLifecycles: true, }) const queryResponse = await storeRef.store.dispatch( enhancedApi.endpoints.query1.initiate(), ) expect(queryResponse.data).toEqual({ value: 'transformed' }) const mutationResponse = await storeRef.store.dispatch( enhancedApi.endpoints.mutation1.initiate(), ) expect('data' in mutationResponse && mutationResponse.data).toEqual({ value: 'transformed', }) }) }) }) describe('additional transformResponse behaviors', () => { type SuccessResponse = { value: 'success' } type EchoResponseData = { banana: 'bread' } type ErrorResponse = { value: 'error' } const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), endpoints: (build) => ({ echo: build.mutation({ query: () => ({ method: 'PUT', url: '/echo' }), }), mutation: build.mutation({ query: () => ({ url: '/echo', method: 'POST', body: { nested: { banana: 'bread' } }, }), transformResponse: (response: { body: { nested: EchoResponseData } }) => response.body.nested, }), mutationWithError: build.mutation({ query: () => ({ url: '/error', method: 'POST', }), transformErrorResponse: (response) => { const data = response.data as ErrorResponse return data.value }, }), mutationWithMeta: build.mutation({ query: () => ({ url: '/echo', method: 'POST', body: { nested: { banana: 'bread' } }, }), transformResponse: ( response: { body: { nested: EchoResponseData } }, meta, ) => { return { ...response.body.nested, meta: { request: { headers: getSerializedHeaders(meta?.request.headers) }, response: { headers: getSerializedHeaders(meta?.response?.headers), }, }, } }, }), query: build.query<SuccessResponse & EchoResponseData, void>({ query: () => '/success', transformResponse: async (response: SuccessResponse) => { const res: any = await nodeFetch('https://example.com/echo', { method: 'POST', body: JSON.stringify({ banana: 'bread' }), }).then((res) => res.json()) const additionalData = res.body as EchoResponseData return { ...response, ...additionalData } }, }), queryWithMeta: build.query<SuccessResponse, void>({ query: () => '/success', transformResponse: async (response: SuccessResponse, meta) => { return { ...response, meta: { request: { headers: getSerializedHeaders(meta?.request.headers) }, response: { headers: getSerializedHeaders(meta?.response?.headers), }, }, } }, }), }), }) const storeRef = setupApiStore(api) test('transformResponse handles an async transformation and returns the merged data (query)', async () => { const result = await storeRef.store.dispatch(api.endpoints.query.initiate()) expect(result.data).toEqual({ value: 'success', banana: 'bread' }) }) test('transformResponse transforms a response from a mutation', async () => { const result = await storeRef.store.dispatch( api.endpoints.mutation.initiate({}), ) expect('data' in result && result.data).toEqual({ banana: 'bread' }) }) test('transformResponse transforms a response from a mutation with an error', async () => { const result = await storeRef.store.dispatch( api.endpoints.mutationWithError.initiate({}), ) expect('error' in result && result.error).toEqual('error') }) test('transformResponse can inject baseQuery meta into the end result from a mutation', async () => { const result = await storeRef.store.dispatch( api.endpoints.mutationWithMeta.initiate({}), ) expect('data' in result && result.data).toEqual({ banana: 'bread', meta: { request: { headers: { 'content-type': 'application/json', }, }, response: { headers: { 'content-type': 'application/json', }, }, }, }) }) test('transformResponse can inject baseQuery meta into the end result from a query', async () => { const result = await storeRef.store.dispatch( api.endpoints.queryWithMeta.initiate(), ) expect(result.data).toEqual({ value: 'success', meta: { request: { headers: {}, }, response: { headers: { 'content-type': 'application/json', }, }, }, }) }) }) describe('query endpoint lifecycles - onStart, onSuccess, onError', () => { const initialState = { count: null as null | number, } const setCount = createAction<number>('setCount') const testReducer = createReducer(initialState, (builder) => { builder.addCase(setCount, (state, action) => { state.count = action.payload }) }) type SuccessResponse = { value: 'success' } const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), endpoints: (build) => ({ echo: build.mutation({ query: () => ({ method: 'PUT', url: '/echo' }), }), query: build.query<SuccessResponse, void>({ query: () => '/success', async onQueryStarted(_, api) { api.dispatch(setCount(0)) try { await api.queryFulfilled api.dispatch(setCount(1)) } catch { api.dispatch(setCount(-1)) } }, }), mutation: build.mutation<SuccessResponse, void>({ query: () => ({ url: '/success', method: 'POST' }), async onQueryStarted(_, api) { api.dispatch(setCount(0)) try { await api.queryFulfilled api.dispatch(setCount(1)) } catch { api.dispatch(setCount(-1)) } }, }), }), }) const storeRef = setupApiStore(api, { testReducer }) test('query lifecycle events fire properly', async () => { // We intentionally fail the first request so we can test all lifecycles server.use( http.get( 'https://example.com/success', () => HttpResponse.json({ value: 'failed' }, { status: 500 }), { once: true }, ), ) expect(storeRef.store.getState().testReducer.count).toBe(null) const failAttempt = storeRef.store.dispatch(api.endpoints.query.initiate()) expect(storeRef.store.getState().testReducer.count).toBe(0) await failAttempt await delay(10) expect(storeRef.store.getState().testReducer.count).toBe(-1) const successAttempt = storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(storeRef.store.getState().testReducer.count).toBe(0) await successAttempt await delay(10) expect(storeRef.store.getState().testReducer.count).toBe(1) }) test('mutation lifecycle events fire properly', async () => { // We intentionally fail the first request so we can test all lifecycles server.use( http.post( 'https://example.com/success', () => HttpResponse.json({ value: 'failed' }, { status: 500 }), { once: true }, ), ) expect(storeRef.store.getState().testReducer.count).toBe(null) const failAttempt = storeRef.store.dispatch( api.endpoints.mutation.initiate(), ) expect(storeRef.store.getState().testReducer.count).toBe(0) await failAttempt expect(storeRef.store.getState().testReducer.count).toBe(-1) const successAttempt = storeRef.store.dispatch( api.endpoints.mutation.initiate(), ) expect(storeRef.store.getState().testReducer.count).toBe(0) await successAttempt expect(storeRef.store.getState().testReducer.count).toBe(1) }) }) test('providesTags and invalidatesTags can use baseQueryMeta', async () => { let _meta: FetchBaseQueryMeta | undefined type SuccessResponse = { value: 'success' } const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), tagTypes: ['success'], endpoints: (build) => ({ query: build.query<SuccessResponse, void>({ query: () => '/success', providesTags: (_result, _error, _arg, meta) => { _meta = meta return ['success'] }, }), mutation: build.mutation<SuccessResponse, void>({ query: () => ({ url: '/success', method: 'POST' }), invalidatesTags: (_result, _error, _arg, meta) => { _meta = meta return ['success'] }, }), }), }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) await storeRef.store.dispatch(api.endpoints.query.initiate()) expect('request' in _meta! && 'response' in _meta!).toBe(true) _meta = undefined await storeRef.store.dispatch(api.endpoints.mutation.initiate()) expect('request' in _meta! && 'response' in _meta!).toBe(true) }) describe('structuralSharing flag behaviors', () => { type SuccessResponse = { value: 'success' } const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), tagTypes: ['success'], endpoints: (build) => ({ enabled: build.query<SuccessResponse, void>({ query: () => '/success', }), disabled: build.query<SuccessResponse, void>({ query: () => ({ url: '/success' }), structuralSharing: false, }), }), }) const storeRef = setupApiStore(api) it('enables structural sharing for query endpoints by default', async () => { await storeRef.store.dispatch(api.endpoints.enabled.initiate()) const firstRef = api.endpoints.enabled.select()(storeRef.store.getState()) await storeRef.store.dispatch( api.endpoints.enabled.initiate(undefined, { forceRefetch: true }), ) const secondRef = api.endpoints.enabled.select()(storeRef.store.getState()) expect(firstRef.requestId).not.toEqual(secondRef.requestId) expect(firstRef.data === secondRef.data).toBeTruthy() }) it('allows a query endpoint to opt-out of structural sharing', async () => { await storeRef.store.dispatch(api.endpoints.disabled.initiate()) const firstRef = api.endpoints.disabled.select()(storeRef.store.getState()) await storeRef.store.dispatch( api.endpoints.disabled.initiate(undefined, { forceRefetch: true }), ) const secondRef = api.endpoints.disabled.select()(storeRef.store.getState()) expect(firstRef.requestId).not.toEqual(secondRef.requestId) expect(firstRef.data === secondRef.data).toBeFalsy() }) }) describe('custom serializeQueryArgs per endpoint', () => { const customArgsSerializer: SerializeQueryArgs<number> = ({ endpointName, queryArgs, }) => `${endpointName}-${queryArgs}` type SuccessResponse = { value: 'success' } const serializer1 = vi.fn(customArgsSerializer) interface MyApiClient { fetchPost: (id: string) => Promise<SuccessResponse> } const dummyClient: MyApiClient = { async fetchPost() { return { value: 'success' } }, } const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), serializeQueryArgs: ({ endpointName, queryArgs }) => `base-${endpointName}-${queryArgs}`, endpoints: (build) => ({ queryWithNoSerializer: build.query<SuccessResponse, number>({ query: (arg) => `${arg}`, }), queryWithCustomSerializer: build.query<SuccessResponse, number>({ query: (arg) => `${arg}`, serializeQueryArgs: serializer1, }), queryWithCustomObjectSerializer: build.query< SuccessResponse, { id: number; client: MyApiClient } >({ query: (arg) => `${arg.id}`, serializeQueryArgs: ({ endpointDefinition, endpointName, queryArgs, }) => { const { id } = queryArgs return { id } }, }), queryWithCustomNumberSerializer: build.query< SuccessResponse, { id: number; client: MyApiClient } >({ query: (arg) => `${arg.id}`, serializeQueryArgs: ({ endpointDefinition, endpointName, queryArgs, }) => { const { id } = queryArgs return id }, }), listItems: build.query<string[], number>({ query: (pageNumber) => `/listItems?page=${pageNumber}`, serializeQueryArgs: ({ endpointName }) => { return endpointName }, merge: (currentCache, newItems) => { currentCache.push(...newItems) }, forceRefetch({ currentArg, previousArg }) { return currentArg !== previousArg }, }), listItems2: build.query<{ items: string[]; meta?: any }, number>({ query: (pageNumber) => `/listItems2?page=${pageNumber}`, serializeQueryArgs: ({ endpointName }) => { return endpointName }, transformResponse(items: string[]) { return { items } }, merge: (currentCache, newData, meta) => { currentCache.items.push(...newData.items) currentCache.meta = meta }, forceRefetch({ currentArg, previousArg }) { return currentArg !== previousArg }, }), }), }) const storeRef = setupApiStore(api) it('Works via createApi', async () => { await storeRef.store.dispatch( api.endpoints.queryWithNoSerializer.initiate(99), ) expect(serializer1).not.toHaveBeenCalled() await storeRef.store.dispatch( api.endpoints.queryWithCustomSerializer.initiate(42), ) expect(serializer1).toHaveBeenCalled() expect( storeRef.store.getState().api.queries['base-queryWithNoSerializer-99'], ).toBeTruthy() expect( storeRef.store.getState().api.queries['queryWithCustomSerializer-42'], ).toBeTruthy() }) const serializer2 = vi.fn(customArgsSerializer) const injectedApi = api.injectEndpoints({ endpoints: (build) => ({ injectedQueryWithCustomSerializer: build.query<SuccessResponse, number>({ query: (arg) => `${arg}`, serializeQueryArgs: serializer2, }), }), }) it('Works via injectEndpoints', async () => { expect(serializer2).not.toHaveBeenCalled() await storeRef.store.dispatch( injectedApi.endpoints.injectedQueryWithCustomSerializer.initiate(5), ) expect(serializer2).toHaveBeenCalled() expect( storeRef.store.getState().api.queries[ 'injectedQueryWithCustomSerializer-5' ], ).toBeTruthy() }) test('Serializes a returned object for query args', async () => { await storeRef.store.dispatch( api.endpoints.queryWithCustomObjectSerializer.initiate({ id: 42, client: dummyClient, }), ) expect( storeRef.store.getState().api.queries[ 'queryWithCustomObjectSerializer({"id":42})' ], ).toBeTruthy() }) test('Serializes a returned primitive for query args', async () => { await storeRef.store.dispatch( api.endpoints.queryWithCustomNumberSerializer.initiate({ id: 42, client: dummyClient, }), ) expect( storeRef.store.getState().api.queries[ 'queryWithCustomNumberSerializer(42)' ], ).toBeTruthy() }) test('serializeQueryArgs + merge allows refetching as args change with same cache key', async () => { const allItems = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'i'] const PAGE_SIZE = 3 server.use( http.get('https://example.com/listItems', ({ request }) => { const url = new URL(request.url) const pageString = url.searchParams.get('page') const pageNum = parseInt(pageString || '0') const results = paginate(allItems, PAGE_SIZE, pageNum) return HttpResponse.json(results) }), ) // Page number shouldn't matter here, because the cache key ignores that. // We just need to select the only cache entry. const selectListItems = api.endpoints.listItems.select(0) await storeRef.store.dispatch(api.endpoints.listItems.initiate(1)) const initialEntry = selectListItems(storeRef.store.getState()) expect(initialEntry.data).toEqual(['a', 'b', 'c']) await storeRef.store.dispatch(api.endpoints.listItems.initiate(2)) const updatedEntry = selectListItems(storeRef.store.getState()) expect(updatedEntry.data).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) }) test('merge receives a meta object as an argument', async () => { const allItems = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'i'] const PAGE_SIZE = 3 server.use( http.get('https://example.com/listItems2', ({ request }) => { const url = new URL(request.url) const pageString = url.searchParams.get('page') const pageNum = parseInt(pageString || '0') const results = paginate(allItems, PAGE_SIZE, pageNum) return HttpResponse.json(results) }), ) const selectListItems = api.endpoints.listItems2.select(0) await storeRef.store.dispatch(api.endpoints.listItems2.initiate(1)) await storeRef.store.dispatch(api.endpoints.listItems2.initiate(2)) const cacheEntry = selectListItems(storeRef.store.getState()) // Should have passed along the third arg from `merge` containing these fields expect(cacheEntry.data?.meta).toEqual({ requestId: expect.any(String), fulfilledTimeStamp: expect.any(Number), arg: 2, baseQueryMeta: expect.any(Object), }) }) }) describe('timeout behavior', () => { test('triggers TIMEOUT_ERROR', async () => { const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com', timeout: 5 }), endpoints: (build) => ({ query: build.query<unknown, void>({ query: () => '/success', }), }), }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) server.use( http.get( 'https://example.com/success', async () => { await delay(50) return HttpResponse.json({ value: 'failed' }, { status: 500 }) }, { once: true }, ), ) const result = await storeRef.store.dispatch(api.endpoints.query.initiate()) expect(result?.error).toEqual({ status: 'TIMEOUT_ERROR', error: expect.stringMatching(/^AbortError:/), }) }) }) describe('endpoint schemas', () => { const schemaConverter: SchemaFailureConverter< ReturnType<typeof fetchBaseQuery> > = (error) => { return { status: 'CUSTOM_ERROR', error: error.schemaName + ' failed validation', data: error.issues, } } const serializedSchemaError = { name: 'SchemaError', message: expect.any(String), stack: expect.any(String), } satisfies SerializedError const onSchemaFailureGlobal = vi.fn<Parameters<SchemaFailureHandler>>() const onSchemaFailureEndpoint = vi.fn<Parameters<SchemaFailureHandler>>() afterEach(() => { onSchemaFailureGlobal.mockClear() onSchemaFailureEndpoint.mockClear() }) function expectFailureHandlersToHaveBeenCalled({ schemaName, value, arg, }: { schemaName: string value: unknown arg: unknown }) { for (const handler of [onSchemaFailureGlobal, onSchemaFailureEndpoint]) { expect(handler).toHaveBeenCalledOnce() const [namedError, info] = handler.mock.calls[0] expect(namedError).toBeInstanceOf(NamedSchemaError) expect(namedError.issues.length).toBeGreaterThan(0) expect(namedError.value).toEqual(value) expect(namedError.schemaName).toBe(schemaName) expect(info.endpoint).toBe('query') expect(info.type).toBe('query') expect(info.arg).toEqual(arg) } } describe('argSchema', () => { const makeApi = ({ globalSkip, endpointSkip, globalCatch, endpointCatch, }: { globalSkip?: boolean endpointSkip?: boolean globalCatch?: boolean endpointCatch?: boolean } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, skipSchemaValidation: globalSkip, catchSchemaFailure: globalCatch ? schemaConverter : undefined, endpoints: (build) => ({ query: build.query<unknown, { id: number }>({ query: ({ id }) => `/post/${id}`, argSchema: v.object({ id: v.number() }), onSchemaFailure: onSchemaFailureEndpoint, skipSchemaValidation: endpointSkip, catchSchemaFailure: endpointCatch ? schemaConverter : undefined, }), }), }) test("can be used to validate the endpoint's arguments", async () => { const api = makeApi() const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate({ id: 1 }), ) expect(result?.error).toBeUndefined() const invalidResult = await storeRef.store.dispatch( // @ts-expect-error api.endpoints.query.initiate({ id: '1' }), ) expect(invalidResult?.error).toEqual(serializedSchemaError) expectFailureHandlersToHaveBeenCalled({ schemaName: 'argSchema', value: { id: '1' }, arg: { id: '1' }, }) }) test('can be skipped globally', async () => { const api = makeApi({ globalSkip: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( // @ts-expect-error api.endpoints.query.initiate({ id: '1' }), ) expect(result?.error).toBeUndefined() }) test('can be skipped on the endpoint', async () => { const api = makeApi({ endpointSkip: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( // @ts-expect-error api.endpoints.query.initiate({ id: '1' }), ) expect(result?.error).toBeUndefined() }) // we only need to test this once test('endpoint overrides global skip', async () => { const api = makeApi({ globalSkip: true, endpointSkip: false }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( // @ts-expect-error api.endpoints.query.initiate({ id: '1' }), ) expect(result?.error).toEqual(serializedSchemaError) }) test('can be converted to a standard error object at global level', async () => { const api = makeApi({ globalCatch: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( // @ts-expect-error api.endpoints.query.initiate({ id: '1' }), ) expect(result?.error).toEqual({ status: 'CUSTOM_ERROR', error: 'argSchema failed validation', data: expect.any(Array), }) expectFailureHandlersToHaveBeenCalled({ schemaName: 'argSchema', value: { id: '1' }, arg: { id: '1' }, }) }) test('can be converted to a standard error object at endpoint level', async () => { const api = makeApi({ endpointCatch: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( // @ts-expect-error api.endpoints.query.initiate({ id: '1' }), ) expect(result?.error).toEqual({ status: 'CUSTOM_ERROR', error: 'argSchema failed validation', data: expect.any(Array), }) expectFailureHandlersToHaveBeenCalled({ schemaName: 'argSchema', value: { id: '1' }, arg: { id: '1' }, }) }) }) describe('rawResponseSchema', () => { const makeApi = ({ globalSkip, endpointSkip, globalCatch, endpointCatch, }: { globalSkip?: boolean endpointSkip?: boolean globalCatch?: boolean endpointCatch?: boolean } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ query: () => '/success', rawResponseSchema: v.object({ value: v.literal('success!') }), onSchemaFailure: onSchemaFailureEndpoint, catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), }) test("can be used to validate the endpoint's raw result", async () => { const api = makeApi() const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toEqual(serializedSchemaError) expectFailureHandlersToHaveBeenCalled({ schemaName: 'rawResponseSchema', value: { value: 'success' }, arg: undefined, }) }) test('can be skipped globally', async () => { const api = makeApi({ globalSkip: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toBeUndefined() }) test('can be skipped on the endpoint', async () => { const api = makeApi({ endpointSkip: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toBeUndefined() }) test('can be converted to a standard error object at global level', async () => { const api = makeApi({ globalCatch: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toEqual({ status: 'CUSTOM_ERROR', error: 'rawResponseSchema failed validation', data: expect.any(Array), }) expectFailureHandlersToHaveBeenCalled({ schemaName: 'rawResponseSchema', value: { value: 'success' }, arg: undefined, }) }) test('can be converted to a standard error object at endpoint level', async () => { const api = makeApi({ endpointCatch: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toEqual({ status: 'CUSTOM_ERROR', error: 'rawResponseSchema failed validation', data: expect.any(Array), }) expectFailureHandlersToHaveBeenCalled({ schemaName: 'rawResponseSchema', value: { value: 'success' }, arg: undefined, }) }) }) describe('responseSchema', () => { const makeApi = ({ globalSkip, endpointSkip, globalCatch, endpointCatch, }: { globalSkip?: boolean endpointSkip?: boolean globalCatch?: boolean endpointCatch?: boolean } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ query: () => '/success', transformResponse: () => ({ success: false }), responseSchema: v.object({ success: v.literal(true) }), onSchemaFailure: onSchemaFailureEndpoint, catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), }) test("can be used to validate the endpoint's final result", async () => { const api = makeApi() const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toEqual(serializedSchemaError) expectFailureHandlersToHaveBeenCalled({ schemaName: 'responseSchema', value: { success: false }, arg: undefined, }) }) test('can be skipped globally', async () => { const api = makeApi({ globalSkip: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toBeUndefined() }) test('can be skipped on the endpoint', async () => { const api = makeApi({ endpointSkip: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toBeUndefined() }) test('can be converted to a standard error object at global level', async () => { const api = makeApi({ globalCatch: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toEqual({ status: 'CUSTOM_ERROR', error: 'responseSchema failed validation', data: expect.any(Array), }) expectFailureHandlersToHaveBeenCalled({ schemaName: 'responseSchema', value: { success: false }, arg: undefined, }) }) test('can be converted to a standard error object at endpoint level', async () => { const api = makeApi({ endpointCatch: true }) const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result = await storeRef.store.dispatch( api.endpoints.query.initiate(), ) expect(result?.error).toEqual({ status: 'CUSTOM_ERROR', error: 'responseSchema failed validation', data: expect.any(Array), }) expectFailureHandlersToHaveBeenCalled({ schemaName: 'responseSchema', value: { success: false }, arg: undefined, }) }) }) describe('rawErrorResponseSchema', () => { const makeApi = ({ globalSkip, endpointSkip, globalCatch, endpointCatch, }: { globalSkip?: boolean endpointSkip?: boolean globalCatch?: boolean endpointCatch?: boolean } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ query: () => '/error', rawErrorResponseSchema: v.object({ status: v.pipe(v.number(), v.minValue(400), v.maxValue(499)), data: v.unknown(), }), onSchemaFailure: onSchemaFailureEndpoint, catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), }) test("can be used to validate the endpoint's raw error result", async () => { const api = makeApi() const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) const result =