mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Merge pull request #489 from acelaya-forks/feature/coverage-80
Feature/coverage 80
This commit is contained in:
commit
691dabcfbc
33 changed files with 516 additions and 85 deletions
|
@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
### Changed
|
||||
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
||||
* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand.
|
||||
* [#409](https://github.com/shlinkio/shlink-web-client/issues/409) Increased required code coverage and added hard threshold check.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
module.exports = {
|
||||
coverageDirectory: '<rootDir>/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,ts,tsx}',
|
||||
'!src/registerServiceWorker.js',
|
||||
'!src/index.ts',
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/*.{ts,tsx}',
|
||||
'!src/reducers/index.ts',
|
||||
'!src/**/provideServices.ts',
|
||||
'!src/container/*.ts',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 85,
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
'react-app-polyfill/jsdom',
|
||||
|
|
|
@ -16,8 +16,9 @@
|
|||
"serve:build": "serve ./build",
|
||||
"build": "node scripts/build.js",
|
||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
|
@ -19,6 +18,7 @@ import {
|
|||
ShlinkEditDomainRedirects,
|
||||
ShlinkDomainRedirects,
|
||||
} from '../types';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
|
@ -123,7 +123,7 @@ export default class ShlinkApiClient {
|
|||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
paramsSerializer: stringifyQuery,
|
||||
});
|
||||
} catch (e) {
|
||||
const { response } = e;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import './utils/base';
|
||||
@import '../utils/base';
|
||||
|
||||
.app-container {
|
||||
height: 100%;
|
|
@ -1,11 +1,11 @@
|
|||
import { useEffect, FC } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import NotFound from './common/NotFound';
|
||||
import { ServersMap } from './servers/data';
|
||||
import { Settings } from './settings/reducers/settings';
|
||||
import { changeThemeInMarkup } from './utils/theme';
|
||||
import { AppUpdateBanner } from './common/AppUpdateBanner';
|
||||
import { forceUpdate } from './utils/helpers/sw';
|
||||
import NotFound from '../common/NotFound';
|
||||
import { ServersMap } from '../servers/data';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import { changeThemeInMarkup } from '../utils/theme';
|
||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||
import { forceUpdate } from '../utils/helpers/sw';
|
||||
import './App.scss';
|
||||
|
||||
interface AppProps {
|
|
@ -1,6 +1,6 @@
|
|||
import Bottle from 'bottlejs';
|
||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||
import App from '../../App';
|
||||
import App from '../App';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
|
|
|
@ -31,7 +31,7 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
|
|
|
@ -34,7 +34,7 @@ const RealTimeUpdates = (
|
|||
placeholder="Immediate"
|
||||
disabled={!realTimeUpdates.enabled}
|
||||
value={intervalValue(realTimeUpdates.interval)}
|
||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
|
|
|
@ -12,7 +12,7 @@ export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
|||
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
||||
*/
|
||||
|
||||
interface RealTimeUpdatesSettings {
|
||||
export interface RealTimeUpdatesSettings {
|
||||
enabled: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
@ -25,10 +25,12 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
|
||||
const { editing, error, errorData } = tagEdit;
|
||||
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}));
|
||||
const saveTag = handleEventPreventingDefault(
|
||||
async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
||||
|
@ -47,13 +49,11 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||
</Popover>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
value={newTagName}
|
||||
placeholder="Tag"
|
||||
required
|
||||
className="form-control"
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onChange={({ target }) => setNewTagName(target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -64,8 +64,8 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
|
||||
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { Settings } from '../src/settings/reducers/settings';
|
||||
import appFactory from '../src/App';
|
||||
import { AppUpdateBanner } from '../src/common/AppUpdateBanner';
|
||||
import { Settings } from '../../src/settings/reducers/settings';
|
||||
import appFactory from '../../src/app/App';
|
||||
import { AppUpdateBanner } from '../../src/common/AppUpdateBanner';
|
||||
|
||||
describe('<App />', () => {
|
||||
let wrapper: ShallowWrapper;
|
|
@ -17,13 +17,13 @@ describe('appUpdatesReducer', () => {
|
|||
});
|
||||
|
||||
describe('appUpdateAvailable', () => {
|
||||
test('creates expected action', () => {
|
||||
it('creates expected action', () => {
|
||||
expect(appUpdateAvailable()).toEqual({ type: APP_UPDATE_AVAILABLE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetAppUpdate', () => {
|
||||
test('creates expected action', () => {
|
||||
it('creates expected action', () => {
|
||||
expect(resetAppUpdate()).toEqual({ type: RESET_APP_UPDATE });
|
||||
});
|
||||
});
|
||||
|
|
62
test/common/MainHeader.test.tsx
Normal file
62
test/common/MainHeader.test.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { match } from 'react-router';
|
||||
import { History, Location } from 'history';
|
||||
import { Collapse, NavbarToggler, NavLink } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import createMainHeader from '../../src/common/MainHeader';
|
||||
|
||||
describe('<MainHeader />', () => {
|
||||
const ServersDropdown = () => null;
|
||||
const MainHeader = createMainHeader(ServersDropdown);
|
||||
let wrapper: ShallowWrapper;
|
||||
|
||||
const createWrapper = (pathname = '') => {
|
||||
const location = Mock.of<Location>({ pathname });
|
||||
|
||||
wrapper = shallow(<MainHeader history={Mock.all<History>()} location={location} match={Mock.all<match>()} />);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders ServersDropdown', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(ServersDropdown)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ '/foo', false ],
|
||||
[ '/bar', false ],
|
||||
[ '/settings', true ],
|
||||
])('sets link to settings as active only when current path is settings', (currentPath, isActive) => {
|
||||
const wrapper = createWrapper(currentPath);
|
||||
const settingsLink = wrapper.find(NavLink);
|
||||
|
||||
expect(settingsLink.prop('active')).toEqual(isActive);
|
||||
});
|
||||
|
||||
it('renders expected class based on the nav bar state', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(NavbarToggler).find(FontAwesomeIcon).prop('className')).toEqual('main-header__toggle-icon');
|
||||
wrapper.find(NavbarToggler).simulate('click');
|
||||
expect(wrapper.find(NavbarToggler).find(FontAwesomeIcon).prop('className')).toEqual(
|
||||
'main-header__toggle-icon main-header__toggle-icon--opened',
|
||||
);
|
||||
wrapper.find(NavbarToggler).simulate('click');
|
||||
expect(wrapper.find(NavbarToggler).find(FontAwesomeIcon).prop('className')).toEqual('main-header__toggle-icon');
|
||||
});
|
||||
|
||||
it('opens Collapse when clicking toggle', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(Collapse).prop('isOpen')).toEqual(false);
|
||||
wrapper.find(NavbarToggler).simulate('click');
|
||||
expect(wrapper.find(Collapse).prop('isOpen')).toEqual(true);
|
||||
wrapper.find(NavbarToggler).simulate('click');
|
||||
expect(wrapper.find(Collapse).prop('isOpen')).toEqual(false);
|
||||
});
|
||||
});
|
|
@ -14,7 +14,7 @@ describe('<ShlinkVersionsContainer />', () => {
|
|||
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
test.each([
|
||||
it.each([
|
||||
[ null, 'text-center' ],
|
||||
[ Mock.of<NotFoundServer>({ serverNotFound: true }), 'text-center' ],
|
||||
[ Mock.of<NonReachableServer>({ serverNotReachable: true }), 'text-center' ],
|
||||
|
|
25
test/common/services/ImageDownloader.test.ts
Normal file
25
test/common/services/ImageDownloader.test.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Mock } from 'ts-mockery';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { ImageDownloader } from '../../../src/common/services/ImageDownloader';
|
||||
import { windowMock } from '../../mocks/WindowMock';
|
||||
|
||||
describe('ImageDownloader', () => {
|
||||
const get = jest.fn();
|
||||
const axios = Mock.of<AxiosInstance>({ get });
|
||||
let imageDownloader: ImageDownloader;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global as any).URL = { createObjectURL: () => '' };
|
||||
|
||||
imageDownloader = new ImageDownloader(axios, windowMock);
|
||||
});
|
||||
|
||||
it('calls URL with response type blob', async () => {
|
||||
get.mockResolvedValue({ data: {} });
|
||||
|
||||
await imageDownloader.saveImage('/foo/bar.png', 'my-image.png');
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/foo/bar.png', { responseType: 'blob' });
|
||||
});
|
||||
});
|
|
@ -10,28 +10,40 @@ describe('<DomainSelector />', () => {
|
|||
let wrapper: ShallowWrapper;
|
||||
const domainsList = Mock.of<DomainsList>({
|
||||
domains: [
|
||||
Mock.of<ShlinkDomain>({ domain: 'default.com', isDefault: true }),
|
||||
Mock.of<ShlinkDomain>({ domain: 'foo.com' }),
|
||||
Mock.of<ShlinkDomain>({ domain: 'bar.com' }),
|
||||
],
|
||||
});
|
||||
const createWrapper = (value = '') => {
|
||||
wrapper = shallow(
|
||||
<DomainSelector value={value} domainsList={domainsList} listDomains={jest.fn()} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<DomainSelector domainsList={domainsList} listDomains={jest.fn()} onChange={jest.fn()} />);
|
||||
});
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterEach(() => wrapper.unmount());
|
||||
|
||||
it('shows dropdown by default', () => {
|
||||
it.each([
|
||||
[ '', 'Domain', 'domains-dropdown__toggle-btn' ],
|
||||
[ 'my-domain.com', 'Domain: my-domain.com', 'domains-dropdown__toggle-btn--active' ],
|
||||
])('shows dropdown by default', (value, expectedText, expectedClassName) => {
|
||||
const wrapper = createWrapper(value);
|
||||
const input = wrapper.find(InputGroup);
|
||||
const dropdown = wrapper.find(DropdownBtn);
|
||||
|
||||
expect(input).toHaveLength(0);
|
||||
expect(dropdown).toHaveLength(1);
|
||||
expect(dropdown.find(DropdownItem)).toHaveLength(4);
|
||||
expect(dropdown.find(DropdownItem)).toHaveLength(5);
|
||||
expect(dropdown.prop('text')).toEqual(expectedText);
|
||||
expect(dropdown.prop('className')).toEqual(expectedClassName);
|
||||
});
|
||||
|
||||
it('allows to toggle between dropdown and input', () => {
|
||||
it('allows toggling between dropdown and input', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
wrapper.find(DropdownItem).last().simulate('click');
|
||||
expect(wrapper.find(InputGroup)).toHaveLength(1);
|
||||
expect(wrapper.find(DropdownBtn)).toHaveLength(0);
|
||||
|
@ -40,4 +52,14 @@ describe('<DomainSelector />', () => {
|
|||
expect(wrapper.find(InputGroup)).toHaveLength(0);
|
||||
expect(wrapper.find(DropdownBtn)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 0, 'default.com<span class="float-right text-muted">default</span>' ],
|
||||
[ 1, 'foo.com' ],
|
||||
[ 2, 'bar.com' ],
|
||||
])('shows expected content on every item', (index, expectedContent) => {
|
||||
const item = createWrapper().find(DropdownItem).at(index);
|
||||
|
||||
expect(item.html()).toContain(expectedContent);
|
||||
});
|
||||
});
|
||||
|
|
18
test/mocks/WindowMock.ts
Normal file
18
test/mocks/WindowMock.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Mock } from 'ts-mockery';
|
||||
|
||||
const createLinkMock = () => ({
|
||||
setAttribute: jest.fn(),
|
||||
click: jest.fn(),
|
||||
style: {},
|
||||
});
|
||||
|
||||
export const appendChild = jest.fn();
|
||||
|
||||
export const removeChild = jest.fn();
|
||||
|
||||
export const windowMock = Mock.of<Window>({
|
||||
document: {
|
||||
createElement: jest.fn(createLinkMock),
|
||||
body: { appendChild, removeChild },
|
||||
},
|
||||
});
|
|
@ -2,21 +2,9 @@ import { Mock } from 'ts-mockery';
|
|||
import { CsvJson } from 'csvjson';
|
||||
import ServersExporter from '../../../src/servers/services/ServersExporter';
|
||||
import LocalStorage from '../../../src/utils/services/LocalStorage';
|
||||
import { appendChild, removeChild, windowMock } from '../../mocks/WindowMock';
|
||||
|
||||
describe('ServersExporter', () => {
|
||||
const createLinkMock = () => ({
|
||||
setAttribute: jest.fn(),
|
||||
click: jest.fn(),
|
||||
style: {},
|
||||
});
|
||||
const appendChild = jest.fn();
|
||||
const removeChild = jest.fn();
|
||||
const windowMock = Mock.of<Window>({
|
||||
document: {
|
||||
createElement: jest.fn(createLinkMock),
|
||||
body: { appendChild, removeChild },
|
||||
},
|
||||
});
|
||||
const storageMock = Mock.of<LocalStorage>({
|
||||
get: jest.fn(() => ({
|
||||
abc123: {
|
||||
|
|
104
test/settings/RealTimeUpdates.test.tsx
Normal file
104
test/settings/RealTimeUpdates.test.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { Input } from 'reactstrap';
|
||||
import { RealTimeUpdatesSettings, Settings } from '../../src/settings/reducers/settings';
|
||||
import RealTimeUpdates from '../../src/settings/RealTimeUpdates';
|
||||
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
||||
|
||||
describe('<RealTimeUpdates />', () => {
|
||||
const toggleRealTimeUpdates = jest.fn();
|
||||
const setRealTimeUpdatesInterval = jest.fn();
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (realTimeUpdates: Partial<RealTimeUpdatesSettings> = {}) => {
|
||||
const settings = Mock.of<Settings>({ realTimeUpdates });
|
||||
|
||||
wrapper = shallow(
|
||||
<RealTimeUpdates
|
||||
settings={settings}
|
||||
toggleRealTimeUpdates={toggleRealTimeUpdates}
|
||||
setRealTimeUpdatesInterval={setRealTimeUpdatesInterval}
|
||||
/>,
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders enabled real time updates as expected', () => {
|
||||
const wrapper = createWrapper({ enabled: true });
|
||||
const toggle = wrapper.find(ToggleSwitch);
|
||||
const label = wrapper.find('label');
|
||||
const input = wrapper.find(Input);
|
||||
const small = wrapper.find('small');
|
||||
|
||||
expect(toggle.prop('checked')).toEqual(true);
|
||||
expect(toggle.html()).toContain('processed');
|
||||
expect(toggle.html()).not.toContain('ignored');
|
||||
expect(label.prop('className')).toEqual('');
|
||||
expect(input.prop('disabled')).toEqual(false);
|
||||
expect(small).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders disabled real time updates as expected', () => {
|
||||
const wrapper = createWrapper({ enabled: false });
|
||||
const toggle = wrapper.find(ToggleSwitch);
|
||||
const label = wrapper.find('label');
|
||||
const input = wrapper.find(Input);
|
||||
const small = wrapper.find('small');
|
||||
|
||||
expect(toggle.prop('checked')).toEqual(false);
|
||||
expect(toggle.html()).not.toContain('processed');
|
||||
expect(toggle.html()).toContain('ignored');
|
||||
expect(label.prop('className')).toEqual('text-muted');
|
||||
expect(input.prop('disabled')).toEqual(true);
|
||||
expect(small).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 1, 'minute' ],
|
||||
[ 2, 'minutes' ],
|
||||
[ 10, 'minutes' ],
|
||||
[ 100, 'minutes' ],
|
||||
])('shows expected children when interval is greater than 0', (interval, minutesWord) => {
|
||||
const wrapper = createWrapper({ enabled: true, interval });
|
||||
const span = wrapper.find('span');
|
||||
const input = wrapper.find(Input);
|
||||
|
||||
expect(span).toHaveLength(1);
|
||||
expect(span.html()).toEqual(
|
||||
`<span>Updates will be reflected in the UI every <b>${interval}</b> ${minutesWord}.</span>`,
|
||||
);
|
||||
expect(input.prop('value')).toEqual(`${interval}`);
|
||||
});
|
||||
|
||||
it.each([[ undefined ], [ 0 ]])('shows expected children when interval is 0 or undefined', (interval) => {
|
||||
const wrapper = createWrapper({ enabled: true, interval });
|
||||
const span = wrapper.find('span');
|
||||
const small = wrapper.find('small').at(1);
|
||||
const input = wrapper.find(Input);
|
||||
|
||||
expect(span).toHaveLength(0);
|
||||
expect(small.html()).toContain('Updates will be reflected in the UI as soon as they happen.');
|
||||
expect(input.prop('value')).toEqual('');
|
||||
});
|
||||
|
||||
it('updates real time updates on input change', () => {
|
||||
const wrapper = createWrapper();
|
||||
const input = wrapper.find(Input);
|
||||
|
||||
expect(setRealTimeUpdatesInterval).not.toHaveBeenCalled();
|
||||
input.simulate('change', { target: { value: '10' } });
|
||||
expect(setRealTimeUpdatesInterval).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('toggles real time updates on switch change', () => {
|
||||
const wrapper = createWrapper();
|
||||
const toggle = wrapper.find(ToggleSwitch);
|
||||
|
||||
expect(toggleRealTimeUpdates).not.toHaveBeenCalled();
|
||||
toggle.simulate('change');
|
||||
expect(toggleRealTimeUpdates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
18
test/settings/Settings.test.tsx
Normal file
18
test/settings/Settings.test.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import createSettings from '../../src/settings/Settings';
|
||||
import NoMenuLayout from '../../src/common/NoMenuLayout';
|
||||
|
||||
describe('<Settings />', () => {
|
||||
const Component = () => null;
|
||||
const Settings = createSettings(Component, Component, Component, Component);
|
||||
|
||||
it('renders a no-menu layout with the expected settings sections', () => {
|
||||
const wrapper = shallow(<Settings />);
|
||||
const layout = wrapper.find(NoMenuLayout);
|
||||
const sections = wrapper.find('SettingsSections');
|
||||
|
||||
expect(layout).toHaveLength(1);
|
||||
expect(sections).toHaveLength(1);
|
||||
expect((sections.prop('items') as any[]).flat()).toHaveLength(4); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
});
|
||||
});
|
|
@ -4,7 +4,7 @@ import Checkbox from '../../../src/utils/Checkbox';
|
|||
import { InfoTooltip } from '../../../src/utils/InfoTooltip';
|
||||
|
||||
describe('<ShortUrlFormCheckboxGroup />', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[ undefined, '', 0 ],
|
||||
[ 'This is the tooltip', 'mr-2', 1 ],
|
||||
])('renders tooltip only when provided', (infoTooltip, expectedClassName, expectedAmountOfTooltips) => {
|
||||
|
|
109
test/tags/helpers/EditTagModal.test.tsx
Normal file
109
test/tags/helpers/EditTagModal.test.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { Button, Input, Modal, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import { TagEdition } from '../../../src/tags/reducers/tagEdit';
|
||||
import createEditTagModal from '../../../src/tags/helpers/EditTagModal';
|
||||
import ColorGenerator from '../../../src/utils/services/ColorGenerator';
|
||||
import { Result } from '../../../src/utils/Result';
|
||||
import { ProblemDetailsError } from '../../../src/api/types';
|
||||
import { ShlinkApiError } from '../../../src/api/ShlinkApiError';
|
||||
|
||||
describe('<EditTagModal />', () => {
|
||||
const EditTagModal = createEditTagModal(Mock.of<ColorGenerator>({ getColorForKey: jest.fn(() => 'red') }));
|
||||
const editTag = jest.fn().mockReturnValue(Promise.resolve());
|
||||
const tagEdited = jest.fn().mockReturnValue(Promise.resolve());
|
||||
const toggle = jest.fn();
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (tagEdit: Partial<TagEdition> = {}) => {
|
||||
const edition = Mock.of<TagEdition>(tagEdit);
|
||||
|
||||
wrapper = shallow(
|
||||
<EditTagModal isOpen tag="foo" tagEdit={edition} editTag={editTag} tagEdited={tagEdited} toggle={toggle} />,
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('allows modal to be toggled with different mechanisms', () => {
|
||||
const wrapper = createWrapper();
|
||||
const modal = wrapper.find(Modal);
|
||||
const modalHeader = wrapper.find(ModalHeader);
|
||||
const cancelBtn = wrapper.find(Button).findWhere((btn) => btn.prop('type') === 'button');
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
|
||||
(modal.prop('toggle') as Function)();
|
||||
(modalHeader.prop('toggle') as Function)();
|
||||
cancelBtn.simulate('click');
|
||||
|
||||
expect(toggle).toHaveBeenCalledTimes(3);
|
||||
expect(editTag).not.toHaveBeenCalled();
|
||||
expect(tagEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ true, 'Saving...' ],
|
||||
[ false, 'Save' ],
|
||||
])('renders submit button in expected state', (editing, expectedText) => {
|
||||
const wrapper = createWrapper({ editing });
|
||||
const submitBtn = wrapper.find(Button).findWhere((btn) => btn.prop('color') === 'primary');
|
||||
|
||||
expect(submitBtn.html()).toContain(expectedText);
|
||||
expect(submitBtn.prop('disabled')).toEqual(editing);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ true, 1 ],
|
||||
[ false, 0 ],
|
||||
])('displays error result in case of error', (error, expectedResultCount) => {
|
||||
const wrapper = createWrapper({ error, errorData: Mock.all<ProblemDetailsError>() });
|
||||
const result = wrapper.find(Result);
|
||||
const apiError = wrapper.find(ShlinkApiError);
|
||||
|
||||
expect(result).toHaveLength(expectedResultCount);
|
||||
expect(apiError).toHaveLength(expectedResultCount);
|
||||
});
|
||||
|
||||
it('updates tag value when text changes', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(Input).prop('value')).toEqual('foo');
|
||||
wrapper.find(Input).simulate('change', { target: { value: 'bar' } });
|
||||
expect(wrapper.find(Input).prop('value')).toEqual('bar');
|
||||
});
|
||||
|
||||
it('invokes all functions on form submit', async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.find('form');
|
||||
|
||||
expect(editTag).not.toHaveBeenCalled();
|
||||
expect(tagEdited).not.toHaveBeenCalled();
|
||||
|
||||
await form.simulate('submit', { preventDefault: jest.fn() }); // eslint-disable-line @typescript-eslint/await-thenable
|
||||
|
||||
expect(editTag).toHaveBeenCalled();
|
||||
expect(tagEdited).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes color when changing on color picker', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(ChromePicker).prop('color')).toEqual('red');
|
||||
wrapper.find(ChromePicker).simulate('change', { hex: 'blue' });
|
||||
expect(wrapper.find(ChromePicker).prop('color')).toEqual('blue');
|
||||
});
|
||||
|
||||
it('allows toggling popover with different mechanisms', () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.find(Popover).prop('isOpen')).toEqual(false);
|
||||
(wrapper.find(Popover).prop('toggle') as Function)();
|
||||
expect(wrapper.find(Popover).prop('isOpen')).toEqual(true);
|
||||
wrapper.find('.input-group-prepend').simulate('click');
|
||||
expect(wrapper.find(Popover).prop('isOpen')).toEqual(false);
|
||||
});
|
||||
});
|
35
test/utils/PaginationDropdown.test.tsx
Normal file
35
test/utils/PaginationDropdown.test.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import PaginationDropdown from '../../src/utils/PaginationDropdown';
|
||||
|
||||
describe('<PaginationDropdown />', () => {
|
||||
const setValue = jest.fn();
|
||||
let wrapper: ShallowWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallow(<PaginationDropdown ranges={[ 10, 50, 100, 200 ]} value={50} setValue={setValue} />);
|
||||
});
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders expected amount of items', () => {
|
||||
const items = wrapper.find(DropdownItem);
|
||||
|
||||
expect(items).toHaveLength(6);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ 0, 10 ],
|
||||
[ 1, 50 ],
|
||||
[ 2, 100 ],
|
||||
[ 3, 200 ],
|
||||
[ 5, Infinity ],
|
||||
])('sets expected value when an item is clicked', (index, expectedValue) => {
|
||||
const item = wrapper.find(DropdownItem).at(index);
|
||||
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
item.simulate('click');
|
||||
expect(setValue).toHaveBeenCalledWith(expectedValue);
|
||||
});
|
||||
});
|
|
@ -10,7 +10,7 @@ import { parseDate } from '../../../../src/utils/helpers/date';
|
|||
|
||||
describe('date-types', () => {
|
||||
describe('dateRangeIsEmpty', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[ undefined, true ],
|
||||
[{}, true ],
|
||||
[{ startDate: null }, true ],
|
||||
|
@ -24,24 +24,24 @@ describe('date-types', () => {
|
|||
[{ startDate: new Date() }, false ],
|
||||
[{ endDate: new Date() }, false ],
|
||||
[{ startDate: new Date(), endDate: new Date() }, false ],
|
||||
])('proper result is returned', (dateRange, expectedResult) => {
|
||||
])('returns proper result', (dateRange, expectedResult) => {
|
||||
expect(dateRangeIsEmpty(dateRange)).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeIsInterval', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[ undefined, false ],
|
||||
[{}, false ],
|
||||
[ 'today' as DateInterval, true ],
|
||||
[ 'yesterday' as DateInterval, true ],
|
||||
])('proper result is returned', (range, expectedResult) => {
|
||||
])('returns proper result', (range, expectedResult) => {
|
||||
expect(rangeIsInterval(range)).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeOrIntervalToString', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[ undefined, undefined ],
|
||||
[ 'today' as DateInterval, 'Today' ],
|
||||
[ 'yesterday' as DateInterval, 'Yesterday' ],
|
||||
|
@ -65,7 +65,7 @@ describe('date-types', () => {
|
|||
{ startDate: parseDate('2020-01-01', 'yyyy-MM-dd'), endDate: parseDate('2021-02-02', 'yyyy-MM-dd') },
|
||||
'2020-01-01 - 2021-02-02',
|
||||
],
|
||||
])('proper result is returned', (range, expectedValue) => {
|
||||
])('returns proper result', (range, expectedValue) => {
|
||||
expect(rangeOrIntervalToString(range)).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
|
@ -75,7 +75,7 @@ describe('date-types', () => {
|
|||
const daysBack = (days: number) => subDays(new Date(), days);
|
||||
const formatted = (date?: Date | null): string | undefined => !date ? undefined : format(date, 'yyyy-MM-dd');
|
||||
|
||||
test.each([
|
||||
it.each([
|
||||
[ undefined, undefined, undefined ],
|
||||
[ 'today' as DateInterval, now(), now() ],
|
||||
[ 'yesterday' as DateInterval, daysBack(1), daysBack(1) ],
|
||||
|
@ -84,7 +84,7 @@ describe('date-types', () => {
|
|||
[ 'last90Days' as DateInterval, daysBack(90), now() ],
|
||||
[ 'last180days' as DateInterval, daysBack(180), now() ],
|
||||
[ 'last365Days' as DateInterval, daysBack(365), now() ],
|
||||
])('proper result is returned', (interval, expectedStartDate, expectedEndDate) => {
|
||||
])('returns proper result', (interval, expectedStartDate, expectedEndDate) => {
|
||||
const { startDate, endDate } = intervalToDateRange(interval);
|
||||
|
||||
expect(formatted(expectedStartDate)).toEqual(formatted(startDate));
|
||||
|
|
|
@ -2,7 +2,7 @@ import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../../src/ut
|
|||
|
||||
describe('qrCodes', () => {
|
||||
describe('buildQrCodeUrl', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[
|
||||
'foo.com',
|
||||
{ size: 530, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Doughnut } from 'react-chartjs-2';
|
|||
import { keys, values } from 'ramda';
|
||||
import { DoughnutChart } from '../../../src/visits/charts/DoughnutChart';
|
||||
|
||||
describe.skip('<DoughnutChart />', () => {
|
||||
describe('<DoughnutChart />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
const stats = {
|
||||
foo: 123,
|
||||
|
|
|
@ -15,7 +15,7 @@ describe('<DoughnutChartLegend />', () => {
|
|||
},
|
||||
});
|
||||
|
||||
test('renders the expected amount of items with expected colors and labels', () => {
|
||||
it('renders the expected amount of items with expected colors and labels', () => {
|
||||
const wrapper = shallow(<DoughnutChartLegend chart={chart} />);
|
||||
const items = wrapper.find('li');
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { prettify } from '../../../src/utils/helpers/numbers';
|
|||
import { MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../../src/utils/theme';
|
||||
import { HorizontalBarChart } from '../../../src/visits/charts/HorizontalBarChart';
|
||||
|
||||
describe.skip('<HorizontalBarChart />', () => {
|
||||
describe('<HorizontalBarChart />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
const stats = {
|
||||
foo: 123,
|
||||
|
@ -16,7 +16,6 @@ describe.skip('<HorizontalBarChart />', () => {
|
|||
it('renders Bar with expected properties', () => {
|
||||
wrapper = shallow(<HorizontalBarChart stats={stats} />);
|
||||
const horizontal = wrapper.find(Bar);
|
||||
const cols = wrapper.find('.col-sm-12');
|
||||
|
||||
expect(horizontal).toHaveLength(1);
|
||||
|
||||
|
@ -37,7 +36,6 @@ describe.skip('<HorizontalBarChart />', () => {
|
|||
},
|
||||
y: { stacked: true },
|
||||
});
|
||||
expect(cols).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
|
@ -43,9 +43,13 @@ describe('visitsOverviewReducer', () => {
|
|||
expect(visitsCount).toEqual(100);
|
||||
});
|
||||
|
||||
it('returns updated amounts on CREATE_VISITS', () => {
|
||||
it.each([
|
||||
[ 50, 53 ],
|
||||
[ 0, 3 ],
|
||||
[ undefined, 3 ],
|
||||
])('returns updated amounts on CREATE_VISITS', (providedOrphanVisitsCount, expectedOrphanVisitsCount) => {
|
||||
const { visitsCount, orphanVisitsCount } = reducer(
|
||||
state({ visitsCount: 100, orphanVisitsCount: 50 }),
|
||||
state({ visitsCount: 100, orphanVisitsCount: providedOrphanVisitsCount }),
|
||||
{
|
||||
type: CREATE_VISITS,
|
||||
createdVisits: [
|
||||
|
@ -65,7 +69,7 @@ describe('visitsOverviewReducer', () => {
|
|||
);
|
||||
|
||||
expect(visitsCount).toEqual(102);
|
||||
expect(orphanVisitsCount).toEqual(53);
|
||||
expect(orphanVisitsCount).toEqual(expectedOrphanVisitsCount);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -2,19 +2,9 @@ import { Mock } from 'ts-mockery';
|
|||
import { CsvJson } from 'csvjson';
|
||||
import { VisitsExporter } from '../../../src/visits/services/VisitsExporter';
|
||||
import { NormalizedVisit } from '../../../src/visits/types';
|
||||
import { windowMock } from '../../mocks/WindowMock';
|
||||
|
||||
describe('VisitsExporter', () => {
|
||||
const createLinkMock = () => ({
|
||||
setAttribute: jest.fn(),
|
||||
click: jest.fn(),
|
||||
style: {},
|
||||
});
|
||||
const windowMock = Mock.of<Window>({
|
||||
document: {
|
||||
createElement: jest.fn(createLinkMock),
|
||||
body: { appendChild: jest.fn(), removeChild: jest.fn() },
|
||||
},
|
||||
});
|
||||
const toCSV = jest.fn();
|
||||
const csvToJsonMock = Mock.of<CsvJson>({ toCSV });
|
||||
let exporter: VisitsExporter;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { Mock } from 'ts-mockery';
|
||||
import { GroupedNewVisits, groupNewVisitsByType } from '../../../src/visits/types/helpers';
|
||||
import { CreateVisit, OrphanVisit, Visit } from '../../../src/visits/types';
|
||||
import { GroupedNewVisits, groupNewVisitsByType, toApiParams } from '../../../src/visits/types/helpers';
|
||||
import { CreateVisit, OrphanVisit, Visit, VisitsParams } from '../../../src/visits/types';
|
||||
import { ShlinkVisitsParams } from '../../../src/api/types';
|
||||
import { formatIsoDate, parseDate } from '../../../src/utils/helpers/date';
|
||||
|
||||
describe('visitsTypeHelpers', () => {
|
||||
describe('groupNewVisitsByType', () => {
|
||||
|
@ -56,4 +58,51 @@ describe('visitsTypeHelpers', () => {
|
|||
expect(groupNewVisitsByType(createdVisits)).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toApiParams', () => {
|
||||
it.each([
|
||||
[ { page: 5, itemsPerPage: 100 } as VisitsParams, { page: 5, itemsPerPage: 100 } as ShlinkVisitsParams ],
|
||||
[
|
||||
{
|
||||
page: 1,
|
||||
itemsPerPage: 30,
|
||||
filter: { excludeBots: true },
|
||||
} as VisitsParams,
|
||||
{ page: 1, itemsPerPage: 30, excludeBots: true } as ShlinkVisitsParams,
|
||||
],
|
||||
(() => {
|
||||
const endDate = parseDate('2020-05-05', 'yyyy-MM-dd');
|
||||
|
||||
return [
|
||||
{
|
||||
page: 20,
|
||||
itemsPerPage: 1,
|
||||
dateRange: { endDate },
|
||||
} as VisitsParams,
|
||||
{ page: 20, itemsPerPage: 1, endDate: formatIsoDate(endDate) } as ShlinkVisitsParams,
|
||||
];
|
||||
})(),
|
||||
(() => {
|
||||
const startDate = parseDate('2020-05-05', 'yyyy-MM-dd');
|
||||
const endDate = parseDate('2021-10-30', 'yyyy-MM-dd');
|
||||
|
||||
return [
|
||||
{
|
||||
page: 20,
|
||||
itemsPerPage: 1,
|
||||
dateRange: { startDate, endDate },
|
||||
filter: { excludeBots: false },
|
||||
} as VisitsParams,
|
||||
{
|
||||
page: 20,
|
||||
itemsPerPage: 1,
|
||||
startDate: formatIsoDate(startDate),
|
||||
endDate: formatIsoDate(endDate),
|
||||
} as ShlinkVisitsParams,
|
||||
];
|
||||
})(),
|
||||
])('converts param as expected', (visitsParams, expectedResult) => {
|
||||
expect(toApiParams(visitsParams)).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue