From ab7c52d0491a6dac98a3c41e0b75273965cb9aaa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 17:51:37 +0100 Subject: [PATCH] Migrated domainVisits reducer to RTK --- config/jest/setupTests.ts | 3 + src/container/store.ts | 2 +- src/reducers/index.ts | 3 +- src/visits/reducers/common.ts | 75 ++++++++++++- src/visits/reducers/domainVisits.ts | 124 ++++++++++------------ src/visits/services/provideServices.ts | 10 +- test/visits/reducers/domainVisits.test.ts | 122 ++++++++++----------- 7 files changed, 200 insertions(+), 139 deletions(-) diff --git a/config/jest/setupTests.ts b/config/jest/setupTests.ts index cec71a7a..e8900e09 100644 --- a/config/jest/setupTests.ts +++ b/config/jest/setupTests.ts @@ -1,8 +1,11 @@ import '@testing-library/jest-dom'; import 'jest-canvas-mock'; import ResizeObserver from 'resize-observer-polyfill'; +import { setAutoFreeze } from 'immer'; (global as any).ResizeObserver = ResizeObserver; (global as any).scrollTo = () => {}; (global as any).prompt = () => {}; (global as any).matchMedia = (media: string) => ({ matches: false, media }); + +setAutoFreeze(false); // TODO Bypassing a bug on jest diff --git a/src/container/store.ts b/src/container/store.ts index cf48b3c2..590c8382 100644 --- a/src/container/store.ts +++ b/src/container/store.ts @@ -19,7 +19,7 @@ export const setUpStore = (container: IContainer) => configureStore({ reducer: reducer(container), preloadedState, middleware: (defaultMiddlewaresIncludingReduxThunk) => - defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false })// State is too big for these + defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these .prepend(container.selectServerListener.middleware) .concat(save(localStorageConfig)), }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 8d0e05fb..70635df1 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -3,7 +3,6 @@ import { combineReducers } from '@reduxjs/toolkit'; import { serversReducer } from '../servers/reducers/servers'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; -import domainVisitsReducer from '../visits/reducers/domainVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; import { settingsReducer } from '../settings/reducers/settings'; @@ -21,7 +20,7 @@ export default (container: IContainer) => combineReducers({ shortUrlDetail: container.shortUrlDetailReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, - domainVisits: domainVisitsReducer, + domainVisits: container.domainVisitsReducer, orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, tagsList: container.tagsListReducer, diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 1f888413..5516ce83 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,11 +1,13 @@ import { flatten, prop, range, splitEvery } from 'ramda'; -import { Dispatch } from '@reduxjs/toolkit'; +import { createAction, Dispatch } from '@reduxjs/toolkit'; import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types'; import { Visit } from '../types'; import { parseApiError } from '../../api/utils'; import { ApiErrorAction } from '../../api/types/actions'; -import { dateToMatchingInterval } from '../../utils/dates/types'; -import { VisitsLoaded, VisitsLoadProgressChangedAction } from './types'; +import { DateInterval, dateToMatchingInterval } from '../../utils/dates/types'; +import { LoadVisits, VisitsLoaded, VisitsLoadProgressChangedAction } from './types'; +import { createAsyncThunk } from '../../utils/helpers/redux'; +import { ShlinkState } from '../../container/types'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -17,6 +19,13 @@ const calcProgress = (total: number, current: number): number => (current * 100) type VisitsLoader = (page: number, itemsPerPage: number) => Promise; type LastVisitLoader = () => Promise; +interface VisitsAsyncThunkOptions { + actionsPrefix: string; + createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader]; + getExtraFulfilledPayload: (params: T) => Partial; + shouldCancel: (getState: () => ShlinkState) => boolean; +} + export const getVisitsWithLoader = async ( visitsLoader: VisitsLoader, lastVisitLoader: LastVisitLoader, @@ -81,6 +90,66 @@ export const getVisitsWithLoader = async ( } }; +export const createVisitsAsyncThunk = ( + { actionsPrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, +) => { + const progressChangedAction = createAction(`${actionsPrefix}/progressChanged`); + const largeAction = createAction(`${actionsPrefix}/large`); + const fallbackToIntervalAction = createAction(`${actionsPrefix}/fallbackToInterval`); + + const asyncThunk = createAsyncThunk(actionsPrefix, async (params: T, { getState, dispatch }): Promise => { + const [visitsLoader, lastVisitLoader] = createLoaders(params, getState); + + const loadVisitsInParallel = async (pages: number[]): Promise => + Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); + + const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise => { + if (shouldCancel(getState)) { + return []; + } + + const data = await loadVisitsInParallel(pagesBlocks[index]); + + dispatch(progressChangedAction(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE))); + + if (index < pagesBlocks.length - 1) { + return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); + } + + return data; + }; + + const loadVisits = async (page = 1) => { + const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE); + + // If pagination was not returned, then this is an old shlink version. Just return data + if (!pagination || isLastPage(pagination)) { + return data; + } + + // If there are more pages, make requests in blocks of 4 + const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1); + const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange); + + if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { + dispatch(largeAction()); + } + + return data.concat(await loadPagesBlocks(pagesBlocks)); + }; + + const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]); + + if (!visits.length && lastVisit) { + dispatch(fallbackToIntervalAction(dateToMatchingInterval(lastVisit.date))); + } + + return { ...getExtraFulfilledPayload(params), visits } as any; // TODO Get rid of this casting + }); + + return { asyncThunk, progressChangedAction, largeAction, fallbackToIntervalAction }; +}; + export const lastVisitLoaderForLoader = ( doIntervalFallback: boolean, loader: (params: ShlinkVisitsParams) => Promise, diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 1fd46a0e..0cb42ef7 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -1,30 +1,13 @@ -import { createAction } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; -import { buildReducer } from '../../utils/helpers/redux'; +import { createSlice } from '@reduxjs/toolkit'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { GetState } from '../../container/types'; -import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; -import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; -import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; +import { createNewVisits } from './visitCreation'; import { domainMatches } from '../../short-urls/helpers'; -import { - LoadVisits, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoaded, - VisitsLoadedAction, - VisitsLoadProgressChangedAction, -} from './types'; +import { LoadVisits, VisitsInfo } from './types'; +import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/domainVisits'; -export const GET_DOMAIN_VISITS_START = `${REDUCER_PREFIX}/getDomainVisits/pending`; -export const GET_DOMAIN_VISITS_ERROR = `${REDUCER_PREFIX}/getDomainVisits/rejected`; -export const GET_DOMAIN_VISITS = `${REDUCER_PREFIX}/getDomainVisits/fulfilled`; -export const GET_DOMAIN_VISITS_LARGE = `${REDUCER_PREFIX}/getDomainVisits/large`; -export const GET_DOMAIN_VISITS_CANCEL = `${REDUCER_PREFIX}/getDomainVisits/cancel`; -export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getDomainVisits/progressChanged`; -export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`; export const DEFAULT_DOMAIN = 'DEFAULT'; @@ -36,14 +19,6 @@ export interface DomainVisits extends VisitsInfo, WithDomain {} export interface LoadDomainVisits extends LoadVisits, WithDomain {} -type DomainVisitsAction = VisitsLoadedAction; - -type DomainVisitsCombinedAction = DomainVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; - const initialState: DomainVisits = { visits: [], domain: '', @@ -54,44 +29,61 @@ const initialState: DomainVisits = { progress: 0, }; -export default buildReducer({ - [`${REDUCER_PREFIX}/getDomainVisits/pending`]: () => ({ ...initialState, loading: true }), - [`${REDUCER_PREFIX}/getDomainVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getDomainVisits/fulfilled`]: (state, { payload }: DomainVisitsAction) => ( - { ...state, ...payload, loading: false, loadingLarge: false, error: false } - ), - [`${REDUCER_PREFIX}/getDomainVisits/large`]: (state) => ({ ...state, loadingLarge: true }), - [`${REDUCER_PREFIX}/getDomainVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getDomainVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( - { ...state, fallbackInterval } - ), - [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { - const { domain, visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ shortUrl, visit }) => - shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); +export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + actionsPrefix: `${REDUCER_PREFIX}/getDomainVisits`, + createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => { + const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( + domain, + { ...query, page, itemsPerPage }, + ); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); - return { ...state, visits: [...newVisits, ...visits] }; + return [visitsLoader, lastVisitLoader]; }, -}, initialState); + getExtraFulfilledPayload: ({ domain, query = {} }: LoadDomainVisits) => ({ domain, query }), + shouldCancel: (getState) => getState().domainVisits.cancelLoad, +}); -export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, -) => async (dispatch: Dispatch, getState: GetState) => { - const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); - const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( - domain, - { ...query, page, itemsPerPage }, - ); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); - const shouldCancel = () => getState().domainVisits.cancelLoad; - const extraFinishActionData: Partial> = { domain, query }; - const prefix = `${REDUCER_PREFIX}/getDomainVisits`; +export const domainVisitsReducerCreator = ( + { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, +) => { + const { reducer, actions } = createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: { + cancelGetDomainVisits: (state) => ({ ...state, cancelLoad: true }), + }, + extraReducers: (builder) => { + builder.addCase(asyncThunk.pending, () => ({ ...initialState, loading: true })); + builder.addCase(asyncThunk.rejected, (_, { error }) => ( + { ...initialState, error: true, errorData: parseApiError(error) } + )); + builder.addCase(asyncThunk.fulfilled, (state, { payload }) => ( + { ...state, ...payload, loading: false, loadingLarge: false, error: false } + )); - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); + builder.addCase(largeAction, (state) => ({ ...state, loadingLarge: true })); + builder.addCase(progressChangedAction, (state, { payload: progress }) => ({ ...state, progress })); + builder.addCase( + fallbackToIntervalAction, + (state, { payload: fallbackInterval }) => ({ ...state, fallbackInterval }), + ); + + builder.addCase(createNewVisits, (state, { payload }) => { + const { domain, visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = payload.createdVisits + .filter(({ shortUrl, visit }) => + shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }); + }, + }); + + const { cancelGetDomainVisits } = actions; + + return { reducer, cancelGetDomainVisits }; }; - -export const cancelGetDomainVisits = createAction(`${REDUCER_PREFIX}/getDomainVisits/cancel`); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 28684da3..55574ebe 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -8,7 +8,7 @@ import { OrphanVisits } from '../OrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; -import { cancelGetDomainVisits, getDomainVisits } from '../reducers/domainVisits'; +import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits'; import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; @@ -60,8 +60,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); - bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetDomainVisits', () => cancelGetDomainVisits); + bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator'); + bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetDomainVisits'), 'domainVisitsReducerCreator'); bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); @@ -75,6 +76,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Reducers bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview'); bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator'); + + bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisitsCreator'); + bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator'); }; export default provideServices; diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index 4a8cc825..35f24650 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -1,17 +1,10 @@ import { Mock } from 'ts-mockery'; import { addDays, formatISO, subDays } from 'date-fns'; -import reducer, { - getDomainVisits, - cancelGetDomainVisits, - GET_DOMAIN_VISITS_START, - GET_DOMAIN_VISITS_ERROR, - GET_DOMAIN_VISITS, - GET_DOMAIN_VISITS_LARGE, - GET_DOMAIN_VISITS_CANCEL, - GET_DOMAIN_VISITS_PROGRESS_CHANGED, - GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, +import { + getDomainVisits as getDomainVisitsCreator, DomainVisits, DEFAULT_DOMAIN, + domainVisitsReducerCreator, } from '../../../src/visits/reducers/domainVisits'; import { rangeOf } from '../../../src/utils/utils'; import { Visit } from '../../../src/visits/types'; @@ -26,33 +19,34 @@ import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; describe('domainVisitsReducer', () => { const now = new Date(); const visitsMocks = rangeOf(2, () => Mock.all()); + const getDomainVisitsCall = jest.fn(); + const buildApiClientMock = () => Mock.of({ getDomainVisits: getDomainVisitsCall }); + const creator = getDomainVisitsCreator(buildApiClientMock); + const { asyncThunk: getDomainVisits, progressChangedAction, largeAction, fallbackToIntervalAction } = creator; + const { reducer, cancelGetDomainVisits } = domainVisitsReducerCreator(creator); + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const buildState = (data: Partial) => Mock.of(data); it('returns loading on GET_DOMAIN_VISITS_START', () => { - const state = reducer(buildState({ loading: false }), { type: GET_DOMAIN_VISITS_START } as any); - const { loading } = state; - + const { loading } = reducer(buildState({ loading: false }), { type: getDomainVisits.pending.toString() }); expect(loading).toEqual(true); }); it('returns loadingLarge on GET_DOMAIN_VISITS_LARGE', () => { - const state = reducer(buildState({ loadingLarge: false }), { type: GET_DOMAIN_VISITS_LARGE } as any); - const { loadingLarge } = state; - + const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() }); expect(loadingLarge).toEqual(true); }); it('returns cancelLoad on GET_DOMAIN_VISITS_CANCEL', () => { - const state = reducer(buildState({ cancelLoad: false }), { type: GET_DOMAIN_VISITS_CANCEL } as any); - const { cancelLoad } = state; - + const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetDomainVisits.toString() }); expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_DOMAIN_VISITS_ERROR', () => { - const state = reducer(buildState({ loading: true, error: false }), { type: GET_DOMAIN_VISITS_ERROR } as any); + const state = reducer(buildState({ loading: true, error: false }), { type: getDomainVisits.rejected.toString() }); const { loading, error } = state; expect(loading).toEqual(false); @@ -61,11 +55,10 @@ describe('domainVisitsReducer', () => { it('return visits on GET_DOMAIN_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer(buildState({ loading: true, error: false }), { - type: GET_DOMAIN_VISITS, + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getDomainVisits.fulfilled.toString(), payload: { visits: actionVisits }, - } as any); - const { loading, error, visits } = state; + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -128,21 +121,16 @@ describe('domainVisitsReducer', () => { ], ])('prepends new visits on CREATE_VISIT', (state, shortUrlDomain, expectedVisits) => { const shortUrl = Mock.of({ domain: shortUrlDomain }); - const prevState = buildState({ - ...state, - visits: visitsMocks, - }); - - const { visits } = reducer(prevState, { + const { visits } = reducer(buildState({ ...state, visits: visitsMocks }), { type: createNewVisits.toString(), payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] }, - } as any); + }); expect(visits).toHaveLength(expectedVisits); }); it('returns new progress on GET_DOMAIN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_DOMAIN_VISITS_PROGRESS_CHANGED, payload: 85 } as any); + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -151,7 +139,7 @@ describe('domainVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -159,28 +147,25 @@ describe('domainVisitsReducer', () => { }); describe('getDomainVisits', () => { - type GetVisitsReturn = Promise | ((shortCode: string, query: any) => Promise); - - const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ - getDomainVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), - }); const dispatchMock = jest.fn(); const getState = () => Mock.of({ domainVisits: { cancelLoad: false }, }); const domain = 'foo.com'; - beforeEach(jest.clearAllMocks); - it('dispatches start and error when promise is rejected', async () => { - const shlinkApiClient = buildApiClientMock(Promise.reject(new Error())); + getDomainVisitsCall.mockRejectedValue(new Error()); - await getDomainVisits(() => shlinkApiClient)({ domain })(dispatchMock, getState); + await getDomainVisits({ domain })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_DOMAIN_VISITS_ERROR }); - expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getDomainVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getDomainVisits.rejected.toString(), + })); + expect(getDomainVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ @@ -188,37 +173,45 @@ describe('domainVisitsReducer', () => { [{}], ])('dispatches start and success when promise is resolved', async (query) => { const visits = visitsMocks; - const shlinkApiClient = buildApiClientMock(Promise.resolve({ + getDomainVisitsCall.mockResolvedValue({ data: visitsMocks, pagination: { currentPage: 1, pagesCount: 1, totalItems: 1, }, - })); + }); - await getDomainVisits(() => shlinkApiClient)({ domain, query })(dispatchMock, getState); + await getDomainVisits({ domain, query })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { - type: GET_DOMAIN_VISITS, + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getDomainVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getDomainVisits.fulfilled.toString(), payload: { visits, domain, query: query ?? {} }, - }); - expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); + })); + expect(getDomainVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last30Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last30Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last180Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last180Days' }, + 3, ], - [[], expect.objectContaining({ type: GET_DOMAIN_VISITS })], - ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + [[], expect.objectContaining({ type: getDomainVisits.fulfilled.toString() }), 2], + ])('dispatches fallback interval when the list of visits is empty', async ( + lastVisits, + expectedSecondDispatch, + expectedDispatchCalls, + ) => { const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ data, pagination: { @@ -227,22 +220,23 @@ describe('domainVisitsReducer', () => { totalItems: 1, }, }); - const getShlinkDomainVisits = jest.fn() + getDomainVisitsCall .mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult(lastVisits)); - const ShlinkApiClient = Mock.of({ getDomainVisits: getShlinkDomainVisits }); - await getDomainVisits(() => ShlinkApiClient)({ domain, doIntervalFallback: true })(dispatchMock, getState); + await getDomainVisits({ domain, doIntervalFallback: true })(dispatchMock, getState, {}); - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); + expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getDomainVisits.pending.toString(), + })); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); - expect(getShlinkDomainVisits).toHaveBeenCalledTimes(2); + expect(getDomainVisitsCall).toHaveBeenCalledTimes(2); }); }); describe('cancelGetDomainVisits', () => { it('just returns the action with proper type', () => - expect(cancelGetDomainVisits()).toEqual({ type: GET_DOMAIN_VISITS_CANCEL })); + expect(cancelGetDomainVisits()).toEqual(expect.objectContaining({ type: cancelGetDomainVisits.toString() }))); }); });