UNPKG

@reduxjs/toolkit

Version:

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

675 lines (636 loc) 21.2 kB
import { configureStore, createAction, createReducer } from '@reduxjs/toolkit' import type { Api, MutationDefinition, QueryDefinition, } from '@reduxjs/toolkit/query' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' import { ANY, expectType, expectExactType, setupApiStore, waitMs, getSerializedHeaders, } from './helpers' import { server } from './mocks/server' import { rest } from 'msw' const originalEnv = process.env.NODE_ENV beforeAll(() => void ((process.env as any).NODE_ENV = 'development')) afterAll(() => void ((process.env as any).NODE_ENV = originalEnv)) let spy: jest.SpyInstance beforeAll(() => { spy = jest.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { spy.mockReset() }) afterAll(() => { spy.mockRestore() }) test('sensible defaults', () => { const api = createApi({ baseQuery: fetchBaseQuery(), endpoints: (build) => ({ getUser: build.query<unknown, void>({ query(id) { return { url: `user/${id}` } }, }), }), }) configureStore({ reducer: { [api.reducerPath]: api.reducer, }, middleware: (gDM) => gDM().concat(api.middleware), }) expect(api.reducerPath).toBe('api') expectType<'api'>(api.reducerPath) type TagTypes = typeof api extends Api<any, any, any, infer E> ? E : 'no match' expectType<TagTypes>(ANY as never) // @ts-expect-error expectType<TagTypes>(0) }) describe('wrong tagTypes log errors', () => { const baseQuery = jest.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({}) }) 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) => { // @ts-ignore store.dispatch(api.endpoints[endpoint].initiate()) let result: { status: string } do { await waitMs(5) // @ts-ignore result = api.endpoints[endpoint].select()(store.getState()) } while (result.status === 'pending') if (shouldError) { expect(spy).toHaveBeenCalledWith( "Tag type 'Users' was used, but not specified in `tagTypes`!" ) } else { expect(spy).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) => { expectType<'Arg'>(x) return 'From' }, transformResponse(r) { expectType<'To'>(r) return 'RetVal' }, }), queryInference2: (() => { const query = build.query({ query: (x: 'Arg') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }) expectType<QueryDefinition<'Arg', any, any, 'RetVal'>>(query) 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) => { expectType<'Arg'>(x) return 'From' }, transformResponse(r) { expectType<'To'>(r) return 'RetVal' }, }), mutationInference2: (() => { const query = build.mutation({ query: (x: 'Arg') => 'From' as const, transformResponse(r: 'To') { return 'RetVal' as const }, }) expectType<MutationDefinition<'Arg', any, any, 'RetVal'>>(query) return query })(), }), }) }) describe('enhancing endpoint definitions', () => { const baseQuery = jest.fn((x: string) => ({ data: 'success' })) const baseQueryApiMatcher = { dispatch: expect.any(Function), getState: expect.any(Function), signal: expect.any(Object), } 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() let storeRef = setupApiStore(api) beforeEach(() => { api = getNewApi() storeRef = setupApiStore(api) }) test('pre-modification behaviour', async () => { 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', baseQueryApiMatcher, undefined], ['in2', baseQueryApiMatcher, undefined], ['in1', baseQueryApiMatcher, undefined], ['in2', baseQueryApiMatcher, undefined], ]) }) test('warn on wrong tagType', async () => { // 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 waitMs(1) expect(spy).not.toHaveBeenCalled() storeRef.store.dispatch(api.endpoints.query2.initiate('in2')) await waitMs(1) debugger expect(spy).toHaveBeenCalledWith( "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', () => { api.enhanceEndpoints({ endpoints: { query1: { query: (x) => { expectExactType('in1' as const)(x) return 'modified1' }, }, query2(definition) { definition.query = (x) => { expectExactType('in2' as const)(x) return 'modified2' } }, mutation1: { query: (x) => { expectExactType('in1' as const)(x) return 'modified1' }, }, mutation2(definition) { definition.query = (x) => { expectExactType('in2' as const)(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', baseQueryApiMatcher, undefined], ['modified2', baseQueryApiMatcher, undefined], ['modified1', baseQueryApiMatcher, undefined], ['modified2', baseQueryApiMatcher, undefined], ]) }) }) }) describe('additional transformResponse behaviors', () => { type SuccessResponse = { value: 'success' } type EchoResponseData = { banana: 'bread' } const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'http://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, }), 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 = await fetch('http://example.com/echo', { method: 'POST', body: JSON.stringify({ banana: 'bread' }), }).then((res) => res.json()) const additionalData = JSON.parse(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 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', 'x-powered-by': 'msw', }, }, }, }) }) 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', 'x-powered-by': 'msw', }, }, }, }) }) }) 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: 'http://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( rest.get('http://example.com/success', (_, res, ctx) => res.once(ctx.status(500), ctx.json({ value: 'failed' })) ) ) 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 waitMs(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 waitMs(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( rest.post('http://example.com/success', (_, res, ctx) => res.once(ctx.status(500), ctx.json({ value: 'failed' })) ) ) 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) }) })