diff --git a/src/container/types.ts b/src/container/types.ts index f5fc3ba8..9afa2de0 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -12,6 +12,9 @@ import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; import { TagDeletion } from '../tags/reducers/tagDelete'; import { TagEdition } from '../tags/reducers/tagEdit'; import { TagsList } from '../tags/reducers/tagsList'; +import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail'; +import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits'; +import { TagVisits } from '../visits/reducers/tagVisits'; export interface ShlinkState { servers: ServersMap; @@ -23,9 +26,9 @@ export interface ShlinkState { shortUrlTags: ShortUrlTags; shortUrlMeta: ShortUrlMetaEdition; shortUrlEdition: ShortUrlEdition; - shortUrlVisits: any; - tagVisits: any; - shortUrlDetail: any; + shortUrlVisits: ShortUrlVisits; + tagVisits: TagVisits; + shortUrlDetail: ShortUrlDetail; tagsList: TagsList; tagDelete: TagDeletion; tagEdit: TagEdition; diff --git a/src/utils/services/types.ts b/src/utils/services/types.ts index 5e446c55..0f9062f7 100644 --- a/src/utils/services/types.ts +++ b/src/utils/services/types.ts @@ -1,3 +1,5 @@ +import { Visit } from '../../visits/types'; // FIXME Should be defined here + export interface ShlinkMercureInfo { token: string; mercureHubUrl: string; @@ -16,7 +18,17 @@ interface ShlinkTagsStats { export interface ShlinkTags { tags: string[]; - stats?: ShlinkTagsStats[]; + stats?: ShlinkTagsStats[]; // TODO Is only optional in old versions +} + +export interface ShlinkPaginator { + currentPage: number; + pagesCount: number; +} + +export interface ShlinkVisits { + data: Visit[]; + pagination?: ShlinkPaginator; // TODO Is only optional in old versions } export interface ProblemDetailsError { diff --git a/src/visits/reducers/common.js b/src/visits/reducers/common.ts similarity index 57% rename from src/visits/reducers/common.js rename to src/visits/reducers/common.ts index 3b150dcc..4270d12e 100644 --- a/src/visits/reducers/common.js +++ b/src/visits/reducers/common.ts @@ -1,15 +1,55 @@ import { flatten, prop, range, splitEvery } from 'ramda'; +import { Action, Dispatch } from 'redux'; +import { ShlinkPaginator, ShlinkVisits } from '../../utils/services/types'; +import { GetState } from '../../container/types'; +import { Visit } from '../types'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; const PARALLEL_STARTING_PAGE = 2; -const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount; -const calcProgress = (total, current) => current * 100 / total; +const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => currentPage >= pagesCount; +const calcProgress = (total: number, current: number): number => current * 100 / total; -export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, actionMap, dispatch, getState) => { +type VisitsLoader = (page: number, itemsPerPage: number) => Promise; +interface ActionMap { + start: string; + large: string; + finish: string; + error: string; + progress: string; +} + +export const getVisitsWithLoader = async & { visits: Visit[] }>( + visitsLoader: VisitsLoader, + extraFinishActionData: Partial, + actionMap: ActionMap, + dispatch: Dispatch, + getState: GetState, +) => { dispatch({ type: actionMap.start }); + 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 => { + const { shortUrlVisits: { cancelLoad } } = getState(); + + if (cancelLoad) { + return []; + } + + const data = await loadVisitsInParallel(pagesBlocks[index]); + + dispatch({ type: actionMap.progress, progress: 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); @@ -29,27 +69,6 @@ export const getVisitsWithLoader = async (visitsLoader, extraFinishActionData, a return data.concat(await loadPagesBlocks(pagesBlocks)); }; - const loadPagesBlocks = async (pagesBlocks, index = 0) => { - const { shortUrlVisits: { cancelLoad } } = getState(); - - if (cancelLoad) { - return []; - } - - const data = await loadVisitsInParallel(pagesBlocks[index]); - - dispatch({ type: actionMap.progress, progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE) }); - - if (index < pagesBlocks.length - 1) { - return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); - } - - return data; - }; - - const loadVisitsInParallel = (pages) => - Promise.all(pages.map((page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); - try { const visits = await loadVisits(); diff --git a/src/visits/reducers/shortUrlDetail.js b/src/visits/reducers/shortUrlDetail.js deleted file mode 100644 index 7612b7ab..00000000 --- a/src/visits/reducers/shortUrlDetail.js +++ /dev/null @@ -1,40 +0,0 @@ -import { handleActions } from 'redux-actions'; -import PropTypes from 'prop-types'; -import { shortUrlType } from '../../short-urls/reducers/shortUrlsList'; - -/* eslint-disable padding-line-between-statements */ -export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; -export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR'; -export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; -/* eslint-enable padding-line-between-statements */ - -export const shortUrlDetailType = PropTypes.shape({ - shortUrl: shortUrlType, - loading: PropTypes.bool, - error: PropTypes.bool, -}); - -const initialState = { - shortUrl: {}, - loading: false, - error: false, -}; - -export default handleActions({ - [GET_SHORT_URL_DETAIL_START]: () => ({ ...initialState, loading: true }), - [GET_SHORT_URL_DETAIL_ERROR]: () => ({ ...initialState, loading: false, error: true }), - [GET_SHORT_URL_DETAIL]: (state, { shortUrl }) => ({ ...initialState, shortUrl }), -}, initialState); - -export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => { - dispatch({ type: GET_SHORT_URL_DETAIL_START }); - const { getShortUrl } = buildShlinkApiClient(getState); - - try { - const shortUrl = await getShortUrl(shortCode, domain); - - dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL }); - } catch (e) { - dispatch({ type: GET_SHORT_URL_DETAIL_ERROR }); - } -}; diff --git a/src/visits/reducers/shortUrlDetail.ts b/src/visits/reducers/shortUrlDetail.ts new file mode 100644 index 00000000..177380df --- /dev/null +++ b/src/visits/reducers/shortUrlDetail.ts @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import { Action, Dispatch } from 'redux'; +import { shortUrlType } from '../../short-urls/reducers/shortUrlsList'; +import { ShortUrl } from '../../short-urls/data'; +import { buildReducer } from '../../utils/helpers/redux'; +import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder'; +import { OptionalString } from '../../utils/utils'; +import { GetState } from '../../container/types'; + +/* eslint-disable padding-line-between-statements */ +export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; +export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR'; +export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; +/* eslint-enable padding-line-between-statements */ + +/** @deprecated Use ShortUrlDetail interface instead */ +export const shortUrlDetailType = PropTypes.shape({ + shortUrl: shortUrlType, + loading: PropTypes.bool, + error: PropTypes.bool, +}); + +export interface ShortUrlDetail { + shortUrl?: ShortUrl; + loading: boolean; + error: boolean; +} + +export interface ShortUrlDetailAction extends Action { + shortUrl: ShortUrl; +} + +const initialState: ShortUrlDetail = { + loading: false, + error: false, +}; + +export default buildReducer({ + [GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }), + [GET_SHORT_URL_DETAIL_ERROR]: () => ({ loading: false, error: true }), + [GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }), +}, initialState); + +export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + shortCode: string, + domain: OptionalString, +) => async (dispatch: Dispatch, getState: GetState) => { + dispatch({ type: GET_SHORT_URL_DETAIL_START }); + const { getShortUrl } = buildShlinkApiClient(getState); + + try { + const shortUrl = await getShortUrl(shortCode, domain); + + dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL }); + } catch (e) { + dispatch({ type: GET_SHORT_URL_DETAIL_ERROR }); + } +}; diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.ts similarity index 59% rename from src/visits/reducers/shortUrlVisits.js rename to src/visits/reducers/shortUrlVisits.ts index cbbdd081..339f37a9 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,9 +1,14 @@ -import { createAction, handleActions } from 'redux-actions'; import PropTypes from 'prop-types'; +import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../../short-urls/helpers'; -import { VisitType } from '../types'; +import { Visit, VisitsInfo, VisitsLoadProgressChangedAction, VisitType } from '../types'; +import { ShortUrlIdentifier } from '../../short-urls/data'; +import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder'; +import { GetState } from '../../container/types'; +import { OptionalString } from '../../utils/utils'; import { getVisitsWithLoader } from './common'; -import { CREATE_VISIT } from './visitCreation'; +import { CREATE_VISIT, CreateVisitAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; @@ -14,7 +19,8 @@ export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_ export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED'; /* eslint-enable padding-line-between-statements */ -export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType +/** @deprecated Use ShortUrlVisits interface instead */ +export const shortUrlVisitsType = PropTypes.shape({ visits: PropTypes.arrayOf(VisitType), shortCode: PropTypes.string, domain: PropTypes.string, @@ -24,7 +30,15 @@ export const shortUrlVisitsType = PropTypes.shape({ // TODO Should extend from V progress: PropTypes.number, }); -const initialState = { +export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} + +interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { + visits: Visit[]; +} + +type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & CreateVisitAction; + +const initialState: ShortUrlVisits = { visits: [], shortCode: '', domain: undefined, @@ -35,10 +49,10 @@ const initialState = { progress: 0, }; -export default handleActions({ +export default buildReducer({ [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_SHORT_URL_VISITS_ERROR]: () => ({ ...initialState, error: true }), - [GET_SHORT_URL_VISITS]: (state, { visits, shortCode, domain }) => ({ + [GET_SHORT_URL_VISITS]: (_, { visits, shortCode, domain }) => ({ ...initialState, visits, shortCode, @@ -58,10 +72,16 @@ export default handleActions({ }, }, initialState); -export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = {}) => (dispatch, getState) => { +export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + shortCode: string, + query: { domain?: OptionalString } = {}, +) => async (dispatch: Dispatch, getState: GetState) => { const { getShortUrlVisits } = buildShlinkApiClient(getState); - const visitsLoader = (page, itemsPerPage) => getShortUrlVisits(shortCode, { ...query, page, itemsPerPage }); - const extraFinishActionData = { shortCode, domain: query.domain }; + const visitsLoader = (page: number, itemsPerPage: number) => getShortUrlVisits( + shortCode, + { ...query, page, itemsPerPage }, + ); + const extraFinishActionData: Partial = { shortCode, domain: query.domain }; const actionMap = { start: GET_SHORT_URL_VISITS_START, large: GET_SHORT_URL_VISITS_LARGE, @@ -73,4 +93,4 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query = { return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); }; -export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL); +export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL); diff --git a/src/visits/reducers/tagVisits.js b/src/visits/reducers/tagVisits.ts similarity index 59% rename from src/visits/reducers/tagVisits.js rename to src/visits/reducers/tagVisits.ts index d149322b..ab1d0379 100644 --- a/src/visits/reducers/tagVisits.js +++ b/src/visits/reducers/tagVisits.ts @@ -1,8 +1,11 @@ -import { createAction, handleActions } from 'redux-actions'; import PropTypes from 'prop-types'; -import { VisitType } from '../types'; +import { Action, Dispatch } from 'redux'; +import { Visit, VisitsInfo, VisitsLoadProgressChangedAction, VisitType } from '../types'; +import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder'; +import { GetState } from '../../container/types'; import { getVisitsWithLoader } from './common'; -import { CREATE_VISIT } from './visitCreation'; +import { CREATE_VISIT, CreateVisitAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START'; @@ -13,7 +16,8 @@ export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL'; export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED'; /* eslint-enable padding-line-between-statements */ -export const TagVisitsType = PropTypes.shape({ // TODO Should extend from VisitInfoType +/** @deprecated Use TagVisits interface instead */ +export const TagVisitsType = PropTypes.shape({ visits: PropTypes.arrayOf(VisitType), tag: PropTypes.string, loading: PropTypes.bool, @@ -22,7 +26,16 @@ export const TagVisitsType = PropTypes.shape({ // TODO Should extend from VisitI progress: PropTypes.number, }); -const initialState = { +export interface TagVisits extends VisitsInfo { + tag: string; +} + +export interface TagVisitsAction extends Action { + visits: Visit[]; + tag: string; +} + +const initialState: TagVisits = { visits: [], tag: '', loading: false, @@ -32,10 +45,10 @@ const initialState = { progress: 0, }; -export default handleActions({ +export default buildReducer({ [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_TAG_VISITS_ERROR]: () => ({ ...initialState, error: true }), - [GET_TAG_VISITS]: (state, { visits, tag }) => ({ ...initialState, visits, tag }), + [GET_TAG_VISITS]: (_, { visits, tag }) => ({ ...initialState, visits, tag }), [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), @@ -50,10 +63,13 @@ export default handleActions({ }, }, initialState); -export const getTagVisits = (buildShlinkApiClient) => (tag, query = {}) => (dispatch, getState) => { +export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: string, query = {}) => async ( + dispatch: Dispatch, + getState: GetState, +) => { const { getTagVisits } = buildShlinkApiClient(getState); - const visitsLoader = (page, itemsPerPage) => getTagVisits(tag, { ...query, page, itemsPerPage }); - const extraFinishActionData = { tag }; + const visitsLoader = (page: number, itemsPerPage: number) => getTagVisits(tag, { ...query, page, itemsPerPage }); + const extraFinishActionData: Partial = { tag }; const actionMap = { start: GET_TAG_VISITS_START, large: GET_TAG_VISITS_LARGE, @@ -65,4 +81,4 @@ export const getTagVisits = (buildShlinkApiClient) => (tag, query = {}) => (disp return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, getState); }; -export const cancelGetTagVisits = createAction(GET_TAG_VISITS_CANCEL); +export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL); diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 7ade9fd9..b66ee1c4 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import { ShortUrl } from '../../short-urls/data'; +import { Action } from 'redux'; /** @deprecated Use Visit interface instead */ export const VisitType = PropTypes.shape({ @@ -33,6 +34,11 @@ export interface VisitsInfo { loadingLarge: boolean; error: boolean; progress: number; + cancelLoad: boolean; +} + +export interface VisitsLoadProgressChangedAction extends Action { + progress: number; } interface VisitLocation { diff --git a/test/visits/reducers/shortUrlDetail.test.js b/test/visits/reducers/shortUrlDetail.test.ts similarity index 65% rename from test/visits/reducers/shortUrlDetail.test.js rename to test/visits/reducers/shortUrlDetail.test.ts index fb0555c9..582dd2d1 100644 --- a/test/visits/reducers/shortUrlDetail.test.js +++ b/test/visits/reducers/shortUrlDetail.test.ts @@ -1,21 +1,28 @@ +import { Mock } from 'ts-mockery'; import reducer, { getShortUrlDetail, GET_SHORT_URL_DETAIL_START, GET_SHORT_URL_DETAIL_ERROR, GET_SHORT_URL_DETAIL, + ShortUrlDetailAction, } from '../../../src/visits/reducers/shortUrlDetail'; +import { ShortUrl } from '../../../src/short-urls/data'; +import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; +import { ShlinkState } from '../../../src/container/types'; describe('shortUrlDetailReducer', () => { describe('reducer', () => { + const action = (type: string) => Mock.of({ type }); + it('returns loading on GET_SHORT_URL_DETAIL_START', () => { - const state = reducer({ loading: false }, { type: GET_SHORT_URL_DETAIL_START }); + const state = reducer({ loading: false, error: false }, action(GET_SHORT_URL_DETAIL_START)); const { loading } = state; expect(loading).toEqual(true); }); it('stops loading and returns error on GET_SHORT_URL_DETAIL_ERROR', () => { - const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_DETAIL_ERROR }); + const state = reducer({ loading: true, error: false }, action(GET_SHORT_URL_DETAIL_ERROR)); const { loading, error } = state; expect(loading).toEqual(false); @@ -23,7 +30,7 @@ describe('shortUrlDetailReducer', () => { }); it('return short URL on GET_SHORT_URL_DETAIL', () => { - const actionShortUrl = { longUrl: 'foo', shortCode: 'bar' }; + const actionShortUrl = Mock.of({ longUrl: 'foo', shortCode: 'bar' }); const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_DETAIL, shortUrl: actionShortUrl }); const { loading, error, shortUrl } = state; @@ -34,18 +41,18 @@ describe('shortUrlDetailReducer', () => { }); describe('getShortUrlDetail', () => { - const buildApiClientMock = (returned) => ({ - getShortUrl: jest.fn(() => returned), + const buildApiClientMock = (returned: Promise) => Mock.of({ + getShortUrl: jest.fn(async () => returned), }); const dispatchMock = jest.fn(); - const getState = () => ({}); + const getState = () => Mock.of(); beforeEach(() => dispatchMock.mockReset()); it('dispatches start and error when promise is rejected', async () => { const ShlinkApiClient = buildApiClientMock(Promise.reject()); - await getShortUrlDetail(() => ShlinkApiClient)('abc123')(dispatchMock, getState); + await getShortUrlDetail(() => ShlinkApiClient)('abc123', '')(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_DETAIL_START }); @@ -54,10 +61,10 @@ describe('shortUrlDetailReducer', () => { }); it('dispatches start and success when promise is resolved', async () => { - const resolvedShortUrl = { longUrl: 'foo', shortCode: 'bar' }; + const resolvedShortUrl = Mock.of({ longUrl: 'foo', shortCode: 'bar' }); const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedShortUrl)); - await getShortUrlDetail(() => ShlinkApiClient)('abc123')(dispatchMock, getState); + await getShortUrlDetail(() => ShlinkApiClient)('abc123', '')(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_DETAIL_START }); diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.ts similarity index 66% rename from test/visits/reducers/shortUrlVisits.test.js rename to test/visits/reducers/shortUrlVisits.test.ts index 0c295a12..e71bc38c 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -1,3 +1,4 @@ +import { Mock } from 'ts-mockery'; import reducer, { getShortUrlVisits, cancelGetShortUrlVisits, @@ -7,34 +8,44 @@ import reducer, { GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_CANCEL, GET_SHORT_URL_VISITS_PROGRESS_CHANGED, + ShortUrlVisits, } from '../../../src/visits/reducers/shortUrlVisits'; import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; +import { rangeOf } from '../../../src/utils/utils'; +import { Visit } from '../../../src/visits/types'; +import { ShlinkVisits } from '../../../src/utils/services/types'; +import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; +import { ShlinkState } from '../../../src/container/types'; describe('shortUrlVisitsReducer', () => { + const visitsMocks = rangeOf(2, () => Mock.all()); + describe('reducer', () => { + const buildState = (data: Partial) => Mock.of(data); + it('returns loading on GET_SHORT_URL_VISITS_START', () => { - const state = reducer({ loading: false }, { type: GET_SHORT_URL_VISITS_START }); + const state = reducer(buildState({ loading: false }), { type: GET_SHORT_URL_VISITS_START } as any); const { loading } = state; expect(loading).toEqual(true); }); it('returns loadingLarge on GET_SHORT_URL_VISITS_LARGE', () => { - const state = reducer({ loadingLarge: false }, { type: GET_SHORT_URL_VISITS_LARGE }); + const state = reducer(buildState({ loadingLarge: false }), { type: GET_SHORT_URL_VISITS_LARGE } as any); const { loadingLarge } = state; expect(loadingLarge).toEqual(true); }); it('returns cancelLoad on GET_SHORT_URL_VISITS_CANCEL', () => { - const state = reducer({ cancelLoad: false }, { type: GET_SHORT_URL_VISITS_CANCEL }); + const state = reducer(buildState({ cancelLoad: false }), { type: GET_SHORT_URL_VISITS_CANCEL } as any); const { cancelLoad } = state; expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_SHORT_URL_VISITS_ERROR', () => { - const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_VISITS_ERROR }); + const state = reducer(buildState({ loading: true, error: false }), { type: GET_SHORT_URL_VISITS_ERROR } as any); const { loading, error } = state; expect(loading).toEqual(false); @@ -43,7 +54,10 @@ describe('shortUrlVisitsReducer', () => { it('return visits on GET_SHORT_URL_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_VISITS, visits: actionVisits }); + const state = reducer( + buildState({ loading: true, error: false }), + { type: GET_SHORT_URL_VISITS, visits: actionVisits } as any, + ); const { loading, error, visits } = state; expect(loading).toEqual(false); @@ -52,42 +66,44 @@ describe('shortUrlVisitsReducer', () => { }); it.each([ - [{ shortCode: 'abc123' }, [{}, {}, {}]], - [{ shortCode: 'def456' }, [{}, {}]], + [{ shortCode: 'abc123' }, [ ...visitsMocks, {}]], + [{ shortCode: 'def456' }, visitsMocks ], ])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => { const shortUrl = { shortCode: 'abc123', }; - const prevState = { + const prevState = buildState({ ...state, - visits: [{}, {}], - }; + visits: visitsMocks, + }); - const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} }); + const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} } as any); expect(visits).toEqual(expectedVisits); }); it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => { - const state = reducer({}, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, progress: 85 }); + const state = reducer(undefined, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, progress: 85 } as any); - expect(state).toEqual({ progress: 85 }); + expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); }); describe('getShortUrlVisits', () => { - const buildApiClientMock = (returned) => ({ - getShortUrlVisits: jest.fn(typeof returned === 'function' ? returned : () => returned), + type GetVisitsReturn = Promise | ((shortCode: string, query: any) => Promise); + + const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ + getShortUrlVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), }); const dispatchMock = jest.fn(); - const getState = () => ({ - shortUrlVisits: { cancelVisits: false }, + const getState = () => Mock.of({ + shortUrlVisits: Mock.of({ cancelLoad: false }), }); beforeEach(() => dispatchMock.mockReset()); it('dispatches start and error when promise is rejected', async () => { - const ShlinkApiClient = buildApiClientMock(Promise.reject()); + const ShlinkApiClient = buildApiClientMock(Promise.reject() as any); await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); @@ -102,10 +118,10 @@ describe('shortUrlVisitsReducer', () => { [{}, undefined ], [{ domain: 'foobar.com' }, 'foobar.com' ], ])('dispatches start and success when promise is resolved', async (query, domain) => { - const visits = [{}, {}]; + const visits = visitsMocks; const shortCode = 'abc123'; const ShlinkApiClient = buildApiClientMock(Promise.resolve({ - data: visits, + data: visitsMocks, pagination: { currentPage: 1, pagesCount: 1, @@ -122,9 +138,9 @@ describe('shortUrlVisitsReducer', () => { it('performs multiple API requests when response contains more pages', async () => { const expectedRequests = 3; - const ShlinkApiClient = buildApiClientMock((shortCode, { page }) => + const ShlinkApiClient = buildApiClientMock(async (_, { page }) => Promise.resolve({ - data: [{}, {}], + data: visitsMocks, pagination: { currentPage: page, pagesCount: expectedRequests, @@ -135,7 +151,7 @@ describe('shortUrlVisitsReducer', () => { expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests); expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ - visits: [{}, {}, {}, {}, {}, {}], + visits: [ ...visitsMocks, ...visitsMocks, ...visitsMocks ], })); }); }); diff --git a/test/visits/reducers/tagVisits.test.js b/test/visits/reducers/tagVisits.test.ts similarity index 64% rename from test/visits/reducers/tagVisits.test.js rename to test/visits/reducers/tagVisits.test.ts index b39444eb..d7fc93c6 100644 --- a/test/visits/reducers/tagVisits.test.js +++ b/test/visits/reducers/tagVisits.test.ts @@ -1,3 +1,4 @@ +import { Mock } from 'ts-mockery'; import reducer, { getTagVisits, cancelGetTagVisits, @@ -7,34 +8,44 @@ import reducer, { GET_TAG_VISITS_LARGE, GET_TAG_VISITS_CANCEL, GET_TAG_VISITS_PROGRESS_CHANGED, + TagVisits, } from '../../../src/visits/reducers/tagVisits'; import { CREATE_VISIT } from '../../../src/visits/reducers/visitCreation'; +import { rangeOf } from '../../../src/utils/utils'; +import { Visit } from '../../../src/visits/types'; +import { ShlinkVisits } from '../../../src/utils/services/types'; +import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; +import { ShlinkState } from '../../../src/container/types'; describe('tagVisitsReducer', () => { + const visitsMocks = rangeOf(2, () => Mock.all()); + describe('reducer', () => { + const buildState = (data: Partial) => Mock.of(data); + it('returns loading on GET_TAG_VISITS_START', () => { - const state = reducer({ loading: false }, { type: GET_TAG_VISITS_START }); + const state = reducer(buildState({ loading: false }), { type: GET_TAG_VISITS_START } as any); const { loading } = state; expect(loading).toEqual(true); }); it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => { - const state = reducer({ loadingLarge: false }, { type: GET_TAG_VISITS_LARGE }); + const state = reducer(buildState({ loadingLarge: false }), { type: GET_TAG_VISITS_LARGE } as any); const { loadingLarge } = state; expect(loadingLarge).toEqual(true); }); it('returns cancelLoad on GET_TAG_VISITS_CANCEL', () => { - const state = reducer({ cancelLoad: false }, { type: GET_TAG_VISITS_CANCEL }); + const state = reducer(buildState({ cancelLoad: false }), { type: GET_TAG_VISITS_CANCEL } as any); const { cancelLoad } = state; expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_TAG_VISITS_ERROR', () => { - const state = reducer({ loading: true, error: false }, { type: GET_TAG_VISITS_ERROR }); + const state = reducer(buildState({ loading: true, error: false }), { type: GET_TAG_VISITS_ERROR } as any); const { loading, error } = state; expect(loading).toEqual(false); @@ -43,7 +54,10 @@ describe('tagVisitsReducer', () => { it('return visits on GET_TAG_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer({ loading: true, error: false }, { type: GET_TAG_VISITS, visits: actionVisits }); + const state = reducer( + buildState({ loading: true, error: false }), + { type: GET_TAG_VISITS, visits: actionVisits } as any, + ); const { loading, error, visits } = state; expect(loading).toEqual(false); @@ -52,36 +66,38 @@ describe('tagVisitsReducer', () => { }); it.each([ - [{ tag: 'foo' }, [{}, {}, {}]], - [{ tag: 'bar' }, [{}, {}]], + [{ tag: 'foo' }, [ ...visitsMocks, {}]], + [{ tag: 'bar' }, visitsMocks ], ])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => { const shortUrl = { tags: [ 'foo', 'baz' ], }; - const prevState = { + const prevState = buildState({ ...state, - visits: [{}, {}], - }; + visits: visitsMocks, + }); - const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} }); + const { visits } = reducer(prevState, { type: CREATE_VISIT, shortUrl, visit: {} } as any); expect(visits).toEqual(expectedVisits); }); it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => { - const state = reducer({}, { type: GET_TAG_VISITS_PROGRESS_CHANGED, progress: 85 }); + const state = reducer(undefined, { type: GET_TAG_VISITS_PROGRESS_CHANGED, progress: 85 } as any); - expect(state).toEqual({ progress: 85 }); + expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); }); describe('getTagVisits', () => { - const buildApiClientMock = (returned) => ({ - getTagVisits: jest.fn(typeof returned === 'function' ? returned : () => returned), + type GetVisitsReturn = Promise | ((shortCode: string, query: any) => Promise); + + const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ + getTagVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), }); const dispatchMock = jest.fn(); - const getState = () => ({ - tagVisits: { cancelVisits: false }, + const getState = () => Mock.of({ + tagVisits: { cancelLoad: false }, }); beforeEach(jest.resetAllMocks); @@ -101,10 +117,10 @@ describe('tagVisitsReducer', () => { [ undefined ], [{}], ])('dispatches start and success when promise is resolved', async (query) => { - const visits = [{}, {}]; + const visits = visitsMocks; const tag = 'foo'; const ShlinkApiClient = buildApiClientMock(Promise.resolve({ - data: visits, + data: visitsMocks, pagination: { currentPage: 1, pagesCount: 1,