Extended domainsList reducer, adding functionality to verify domains statuses

This commit is contained in:
Alejandro Celaya 2021-12-26 13:38:17 +01:00
parent ace29ca4a4
commit c05c74f009
3 changed files with 152 additions and 17 deletions

View file

@ -0,0 +1,7 @@
import { ShlinkDomain } from '../../api/types';
export type DomainStatus = 'validating' | 'valid' | 'invalid';
export interface Domain extends ShlinkDomain {
status: DomainStatus;
}

View file

@ -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<string> {
domains: ShlinkDomain[];
domains: Domain[];
defaultRedirects?: ShlinkDomainRedirects;
}
@ -32,6 +36,11 @@ interface FilterDomainsAction extends Action<string> {
searchTerm: string;
}
interface ValidateDomain extends Action<string> {
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<DomainsList, DomainsCombinedAction>({
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
@ -61,6 +74,11 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
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<ListDomainsAction>({ 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<ValidateDomain>({ 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<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
} catch (e) {
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
}
};

View file

@ -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<ShlinkDomain>({ domain: 'foo' }), Mock.of<ShlinkDomain>({ domain: 'boo' }) ];
const domains = [ ...filteredDomains, Mock.of<ShlinkDomain>({ domain: 'bar' }) ];
const dispatch = jest.fn();
const getState = jest.fn();
const listDomains = jest.fn();
const health = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ listDomains, health });
const filteredDomains = [
Mock.of<Domain>({ domain: 'foo', status: 'validating' }),
Mock.of<Domain>({ domain: 'boo', status: 'validating' }),
];
const domains = [ ...filteredDomains, Mock.of<Domain>({ domain: 'bar', status: 'validating' }) ];
beforeEach(jest.clearAllMocks);
describe('reducer', () => {
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
@ -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<DomainsList>({ 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<ShlinkApiClient>({ 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<ShlinkState>({
selectedServer: Mock.all<SelectedServer>(),
}));
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<ShlinkState>({
selectedServer: Mock.of<ServerData>({
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<ShlinkState>({
selectedServer: Mock.of<ServerData>({
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 });
});
});
});