diff --git a/src/domains/data/index.ts b/src/domains/data/index.ts new file mode 100644 index 00000000..e427d87d --- /dev/null +++ b/src/domains/data/index.ts @@ -0,0 +1,7 @@ +import { ShlinkDomain } from '../../api/types'; + +export type DomainStatus = 'validating' | 'valid' | 'invalid'; + +export interface Domain extends ShlinkDomain { + status: DomainStatus; +} diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 6ade3fb3..1769b189 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -1,10 +1,13 @@ import { Action, Dispatch } from 'redux'; -import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; +import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { parseApiError } from '../../api/utils'; import { ApiErrorAction } from '../../api/types/actions'; +import { Domain, DomainStatus } from '../data'; +import { hasServerData } from '../../servers/data'; +import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; /* eslint-disable padding-line-between-statements */ @@ -12,11 +15,12 @@ export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START'; export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; +export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; /* eslint-enable padding-line-between-statements */ export interface DomainsList { - domains: ShlinkDomain[]; - filteredDomains: ShlinkDomain[]; + domains: Domain[]; + filteredDomains: Domain[]; defaultRedirects?: ShlinkDomainRedirects; loading: boolean; error: boolean; @@ -24,7 +28,7 @@ export interface DomainsList { } export interface ListDomainsAction extends Action { - domains: ShlinkDomain[]; + domains: Domain[]; defaultRedirects?: ShlinkDomainRedirects; } @@ -32,6 +36,11 @@ interface FilterDomainsAction extends Action { searchTerm: string; } +interface ValidateDomain extends Action { + domain: string; + status: DomainStatus; +} + const initialState: DomainsList = { domains: [], filteredDomains: [], @@ -42,10 +51,14 @@ const initialState: DomainsList = { export type DomainsCombinedAction = ListDomainsAction & ApiErrorAction & FilterDomainsAction -& EditDomainRedirectsAction; +& EditDomainRedirectsAction +& ValidateDomain; export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => - (d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; + (d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects }; + +export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => + (d: Domain): Domain => d.domain !== domain ? d : { ...d, status }; export default buildReducer({ [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), @@ -61,6 +74,11 @@ export default buildReducer({ domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)), filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), }), + [VALIDATE_DOMAIN]: (state, { domain, status }) => ({ + ...state, + domains: state.domains.map(replaceStatusOnDomain(domain, status)), + filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)), + }), }, initialState); export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( @@ -71,7 +89,10 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () const { listDomains } = buildShlinkApiClient(getState); try { - const { data: domains, defaultRedirects } = await listDomains(); + const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({ + domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), + defaultRedirects, + })); dispatch({ type: LIST_DOMAINS, domains, defaultRedirects }); } catch (e: any) { @@ -80,3 +101,30 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () }; export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); + +export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async ( + dispatch: Dispatch, + getState: GetState, +) => { + const { selectedServer } = getState(); + + if (!hasServerData(selectedServer)) { + dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + + return; + } + + try { + const { url, ...rest } = selectedServer; + const { health } = buildShlinkApiClient({ + ...rest, + url: replaceAuthorityFromUri(url, domain), + }); + + const { status } = await health(); + + dispatch({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' }); + } catch (e) { + dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + } +}; diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 83845197..25842eb7 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -4,19 +4,35 @@ import reducer, { LIST_DOMAINS_ERROR, LIST_DOMAINS_START, FILTER_DOMAINS, + VALIDATE_DOMAIN, DomainsCombinedAction, DomainsList, listDomains as listDomainsAction, filterDomains as filterDomainsAction, replaceRedirectsOnDomain, + checkDomainHealth, + replaceStatusOnDomain, } from '../../../src/domains/reducers/domainsList'; import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects'; -import { ShlinkDomain, ShlinkDomainRedirects } from '../../../src/api/types'; +import { ShlinkDomainRedirects } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; +import { Domain } from '../../../src/domains/data'; +import { ShlinkState } from '../../../src/container/types'; +import { SelectedServer, ServerData } from '../../../src/servers/data'; describe('domainsListReducer', () => { - const filteredDomains = [ Mock.of({ domain: 'foo' }), Mock.of({ domain: 'boo' }) ]; - const domains = [ ...filteredDomains, Mock.of({ domain: 'bar' }) ]; + const dispatch = jest.fn(); + const getState = jest.fn(); + const listDomains = jest.fn(); + const health = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ listDomains, health }); + const filteredDomains = [ + Mock.of({ domain: 'foo', status: 'validating' }), + Mock.of({ domain: 'boo', status: 'validating' }), + ]; + const domains = [ ...filteredDomains, Mock.of({ domain: 'bar', status: 'validating' }) ]; + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const action = (type: string, args: Partial = {}) => Mock.of( @@ -66,16 +82,23 @@ describe('domainsListReducer', () => { filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), }); }); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'does_not_exist' ], + ])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => { + expect(reducer( + Mock.of({ domains, filteredDomains }), + action(VALIDATE_DOMAIN, { domain, status: 'valid' }), + )).toEqual({ + domains: domains.map(replaceStatusOnDomain(domain, 'valid')), + filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')), + }); + }); }); describe('listDomains', () => { - const dispatch = jest.fn(); - const getState = jest.fn(); - const listDomains = jest.fn(); - const buildShlinkApiClient = () => Mock.of({ listDomains }); - - beforeEach(jest.clearAllMocks); - it('dispatches error when loading domains fails', async () => { listDomains.mockRejectedValue(new Error('error')); @@ -108,4 +131,61 @@ describe('domainsListReducer', () => { expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm }); }); }); + + describe('checkDomainHealth', () => { + const domain = 'example.com'; + + it('dispatches invalid status when selected server does not have all required data', async () => { + getState.mockReturnValue(Mock.of({ + selectedServer: Mock.all(), + })); + + await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + + expect(getState).toHaveBeenCalledTimes(1); + expect(health).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + }); + + it('dispatches invalid status when health endpoint returns an error', async () => { + getState.mockReturnValue(Mock.of({ + selectedServer: Mock.of({ + url: 'https://myerver.com', + apiKey: '123', + }), + })); + health.mockRejectedValue({}); + + await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + + expect(getState).toHaveBeenCalledTimes(1); + expect(health).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + }); + + it.each([ + [ 'pass', 'valid' ], + [ 'fail', 'invalid' ], + ])('dispatches proper status based on status returned from health endpoint', async ( + healthStatus, + expectedStatus, + ) => { + getState.mockReturnValue(Mock.of({ + selectedServer: Mock.of({ + url: 'https://myerver.com', + apiKey: '123', + }), + })); + health.mockResolvedValue({ status: healthStatus }); + + await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + + expect(getState).toHaveBeenCalledTimes(1); + expect(health).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: expectedStatus }); + }); + }); });