@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
1,628 lines (1,483 loc) • 60.5 kB
text/typescript
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 =