Merge pull request #100 from acelaya/feature/tests

Feature/tests
This commit is contained in:
Alejandro Celaya 2019-01-13 23:38:01 +01:00 committed by GitHub
commit 2d3363153a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 455 additions and 86 deletions

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased] ## 2.0.0 - 2019-01-13
#### Added #### Added
@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#87](https://github.com/shlinkio/shlink-web-client/issues/87) and [#89](https://github.com/shlinkio/shlink-web-client/issues/89) Updated all dependencies to latest major versions. * [#87](https://github.com/shlinkio/shlink-web-client/issues/87) and [#89](https://github.com/shlinkio/shlink-web-client/issues/89) Updated all dependencies to latest major versions.
* [#96](https://github.com/shlinkio/shlink-web-client/issues/96) Updated visits page to load visits in multiple paginated requests of `5000` visits when used shlink server supports it. This will prevent shlink to hang when trying to load big amounts of visits. * [#96](https://github.com/shlinkio/shlink-web-client/issues/96) Updated visits page to load visits in multiple paginated requests of `5000` visits when used shlink server supports it. This will prevent shlink to hang when trying to load big amounts of visits.
* [#71](https://github.com/shlinkio/shlink-web-client/issues/71) Improved tests and increased code coverage.
#### Deprecated #### Deprecated

View file

@ -1,17 +1,17 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const ScrollToTop = (window) => class ScrollToTop extends React.Component { const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
static propTypes = { static propTypes = {
location: PropTypes.object, location: PropTypes.object,
children: PropTypes.node, children: PropTypes.node,
}; };
componentDidUpdate(prevProps) { componentDidUpdate({ location: prevLocation }) {
const { location } = this.props; const { location } = this.props;
if (location !== prevProps.location) { if (location !== prevLocation) {
window.scrollTo(0, 0); scrollTo(0, 0);
} }
} }

View file

@ -13,11 +13,12 @@ import provideUtilsServices from '../utils/services/provideServices';
const bottle = new Bottle(); const bottle = new Bottle();
const { container } = bottle; const { container } = bottle;
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
const mapActionService = (map, actionName) => ({ const mapActionService = (map, actionName) => ({
...map, ...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called // Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: (...args) => container[actionName](...args), [actionName]: lazyService(container, actionName),
}); });
const connect = (propsFromState, actionServiceNames) => const connect = (propsFromState, actionServiceNames) =>
reduxConnect( reduxConnect(

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import { assoc } from 'ramda'; import { assoc, map } from 'ramda';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -22,11 +22,16 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
render() { render() {
const { importServersFromFile } = serversImporter; const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props; const { onImport, createServers } = this.props;
const onChange = (e) => const assocId = (server) => assoc('id', uuid(), server);
importServersFromFile(e.target.files[0]) const onChange = ({ target }) =>
.then((servers) => servers.map((server) => assoc('id', uuid(), server))) importServersFromFile(target.files[0])
.then(map(assocId))
.then(createServers) .then(createServers)
.then(onImport); .then(onImport)
.then(() => {
// Reset input after processing file
target.value = null;
});
return ( return (
<React.Fragment> <React.Fragment>

View file

@ -14,20 +14,20 @@ export const listServers = (serversService) => () => ({
servers: serversService.listServers(), servers: serversService.listServers(),
}); });
export const createServer = (serversService) => (server) => { export const createServer = (serversService, listServers) => (server) => {
serversService.createServer(server); serversService.createServer(server);
return listServers(serversService)(); return listServers();
}; };
export const deleteServer = (serversService) => (server) => { export const deleteServer = (serversService, listServers) => (server) => {
serversService.deleteServer(server); serversService.deleteServer(server);
return listServers(serversService)(); return listServers();
}; };
export const createServers = (serversService) => (servers) => { export const createServers = (serversService, listServers) => (servers) => {
serversService.createServers(servers); serversService.createServers(servers);
return listServers(serversService)(); return listServers();
}; };

View file

@ -35,9 +35,9 @@ const provideServices = (bottle, connect, withRouter) => {
// Actions // Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService'); bottle.serviceFactory('selectServer', selectServer, 'ServersService');
bottle.serviceFactory('createServer', createServer, 'ServersService'); bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService'); bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService'); bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService'); bottle.serviceFactory('listServers', listServers, 'ServersService');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);

View file

@ -14,7 +14,7 @@ import './ShortUrlsList.scss';
const SORTABLE_FIELDS = { const SORTABLE_FIELDS = {
dateCreated: 'Created at', dateCreated: 'Created at',
shortCode: 'Short URL', shortCode: 'Short URL',
originalUrl: 'Long URL', longUrl: 'Long URL',
visits: 'Visits', visits: 'Visits',
}; };
@ -142,9 +142,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
</th> </th>
<th <th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action" className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('originalUrl')} onClick={this.orderByColumn('longUrl')}
> >
{this.renderOrderIcon('originalUrl')} {this.renderOrderIcon('longUrl')}
Long URL Long URL
</th> </th>
<th className="short-urls-list__header-cell">Tags</th> <th className="short-urls-list__header-cell">Tags</th>

View file

@ -43,7 +43,6 @@ const ShortUrlsRow = (
render() { render() {
const { shortUrl, selectedServer } = this.props; const { shortUrl, selectedServer } = this.props;
const completeShortUrl = !selectedServer ? shortUrl.shortCode : `${selectedServer.url}/${shortUrl.shortCode}`;
return ( return (
<tr className="short-urls-row"> <tr className="short-urls-row">
@ -51,10 +50,10 @@ const ShortUrlsRow = (
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment> <Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
</td> </td>
<td className="short-urls-row__cell" data-th="Short URL: "> <td className="short-urls-row__cell" data-th="Short URL: ">
<ExternalLink href={completeShortUrl}>{completeShortUrl}</ExternalLink> <ExternalLink href={shortUrl.shortUrl} />
</td> </td>
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: "> <td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
<ExternalLink href={shortUrl.originalUrl}>{shortUrl.originalUrl}</ExternalLink> <ExternalLink href={shortUrl.longUrl} />
</td> </td>
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td> <td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td> <td className="short-urls-row__cell text-md-right" data-th="Visits: ">{shortUrl.visitsCount}</td>
@ -66,7 +65,6 @@ const ShortUrlsRow = (
Copied short URL! Copied short URL!
</small> </small>
<ShortUrlsRowMenu <ShortUrlsRowMenu
completeShortUrl={completeShortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
shortUrl={shortUrl} shortUrl={shortUrl}
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')} onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}

View file

@ -20,7 +20,6 @@ import './ShortUrlsRowMenu.scss';
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component { const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
static propTypes = { static propTypes = {
completeShortUrl: PropTypes.string,
onCopyToClipboard: PropTypes.func, onCopyToClipboard: PropTypes.func,
selectedServer: serverType, selectedServer: serverType,
shortUrl: shortUrlType, shortUrl: shortUrlType,
@ -29,18 +28,18 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
state = { state = {
isOpen: false, isOpen: false,
isQrModalOpen: false, isQrModalOpen: false,
isPreviewOpen: false, isPreviewModalOpen: false,
isTagsModalOpen: false, isTagsModalOpen: false,
isDeleteModalOpen: false, isDeleteModalOpen: false,
}; };
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen })); toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
render() { render() {
const { completeShortUrl, onCopyToClipboard, selectedServer, shortUrl } = this.props; const { onCopyToClipboard, shortUrl, selectedServer: { id } } = this.props;
const serverId = selectedServer ? selectedServer.id : ''; const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] })); const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
const toggleQrCode = toggleModal('isQrModalOpen'); const toggleQrCode = toggleModal('isQrModalOpen');
const togglePreview = toggleModal('isPreviewOpen'); const togglePreview = toggleModal('isPreviewModalOpen');
const toggleTags = toggleModal('isTagsModalOpen'); const toggleTags = toggleModal('isTagsModalOpen');
const toggleDelete = toggleModal('isDeleteModalOpen'); const toggleDelete = toggleModal('isDeleteModalOpen');
@ -50,7 +49,7 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp; &nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle> </DropdownToggle>
<DropdownMenu right> <DropdownMenu right>
<DropdownItem tag={Link} to={`/server/${serverId}/short-code/${shortUrl.shortCode}/visits`}> <DropdownItem tag={Link} to={`/server/${id}/short-code/${shortUrl.shortCode}/visits`}>
<FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats <FontAwesomeIcon icon={pieChartIcon} /> &nbsp;Visit stats
</DropdownItem> </DropdownItem>
@ -67,31 +66,19 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}> <DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} /> &nbsp;Delete short URL <FontAwesomeIcon icon={deleteIcon} /> &nbsp;Delete short URL
</DropdownItem> </DropdownItem>
<DeleteShortUrlModal <DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
shortUrl={shortUrl}
isOpen={this.state.isDeleteModalOpen}
toggle={toggleDelete}
/>
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem onClick={togglePreview}> <DropdownItem onClick={togglePreview}>
<FontAwesomeIcon icon={pictureIcon} /> &nbsp;Preview <FontAwesomeIcon icon={pictureIcon} /> &nbsp;Preview
</DropdownItem> </DropdownItem>
<PreviewModal <PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
url={completeShortUrl}
isOpen={this.state.isPreviewOpen}
toggle={togglePreview}
/>
<DropdownItem onClick={toggleQrCode}> <DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} /> &nbsp;QR code <FontAwesomeIcon icon={qrIcon} /> &nbsp;QR code
</DropdownItem> </DropdownItem>
<QrCodeModal <QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
url={completeShortUrl}
isOpen={this.state.isQrModalOpen}
toggle={toggleQrCode}
/>
<DropdownItem divider /> <DropdownItem divider />

View file

@ -10,9 +10,10 @@ export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
/* eslint-enable padding-line-between-statements, newline-after-var */ /* eslint-enable padding-line-between-statements, newline-after-var */
export const shortUrlType = PropTypes.shape({ export const shortUrlType = PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string),
shortCode: PropTypes.string, shortCode: PropTypes.string,
originalUrl: PropTypes.string, shortUrl: PropTypes.string,
longUrl: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
}); });
const initialState = { const initialState = {

View file

@ -13,13 +13,12 @@ export default class DeleteTagConfirmModal extends React.Component {
tagDeleted: PropTypes.func, tagDeleted: PropTypes.func,
}; };
doDelete = () => { doDelete = async () => {
const { tag, toggle, deleteTag } = this.props; const { tag, toggle, deleteTag } = this.props;
return deleteTag(tag).then(() => { await deleteTag(tag);
this.tagWasDeleted = true; this.tagWasDeleted = true;
toggle(); toggle();
});
}; };
handleOnClosed = () => { handleOnClosed = () => {
if (!this.tagWasDeleted) { if (!this.tagWasDeleted) {

View file

@ -34,13 +34,7 @@ export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) {
Visit stats for <ExternalLink href={shortLink} /> Visit stats for <ExternalLink href={shortLink} />
</h2> </h2>
<hr /> <hr />
{shortUrl.dateCreated && ( <div>Created: {renderDate()}</div>
<div>
Created:
&nbsp;
{renderDate()}
</div>
)}
<div> <div>
Long URL: Long URL:
&nbsp; &nbsp;

View file

@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import createScrollToTop from '../../src/common/ScrollToTop';
describe('<ScrollToTop />', () => {
let wrapper;
const window = {
scrollTo: sinon.spy(),
};
beforeEach(() => {
const ScrollToTop = createScrollToTop(window);
wrapper = shallow(<ScrollToTop locaction={{ href: 'foo' }}>Foobar</ScrollToTop>);
});
afterEach(() => {
wrapper.unmount();
window.scrollTo.resetHistory();
});
it('just renders children', () => expect(wrapper.text()).toEqual('Foobar'));
it('scrolls to top when location changes', () => {
wrapper.instance().componentDidUpdate({ location: { href: 'bar' } });
expect(window.scrollTo.calledOnce).toEqual(true);
});
});

View file

@ -13,6 +13,7 @@ describe('serverReducer', () => {
abc123: { id: 'abc123' }, abc123: { id: 'abc123' },
def456: { id: 'def456' }, def456: { id: 'def456' },
}; };
const expectedFetchServersResult = { type: FETCH_SERVERS, servers };
const ServersServiceMock = { const ServersServiceMock = {
listServers: sinon.fake.returns(servers), listServers: sinon.fake.returns(servers),
createServer: sinon.fake(), createServer: sinon.fake(),
@ -40,38 +41,38 @@ describe('serverReducer', () => {
it('fetches servers and returns them as part of the action', () => { it('fetches servers and returns them as part of the action', () => {
const result = listServers(ServersServiceMock)(); const result = listServers(ServersServiceMock)();
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.calledOnce).toEqual(true);
expect(ServersServiceMock.createServer.callCount).toEqual(0); expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0); expect(ServersServiceMock.deleteServer.called).toEqual(false);
expect(ServersServiceMock.createServers.callCount).toEqual(0); expect(ServersServiceMock.createServers.called).toEqual(false);
}); });
}); });
describe('createServer', () => { describe('createServer', () => {
it('adds new server and then fetches servers again', () => { it('adds new server and then fetches servers again', () => {
const serverToCreate = { id: 'abc123' }; const serverToCreate = { id: 'abc123' };
const result = createServer(ServersServiceMock)(serverToCreate); const result = createServer(ServersServiceMock, () => expectedFetchServersResult)(serverToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.createServer.calledOnce).toEqual(true);
expect(ServersServiceMock.createServer.callCount).toEqual(1);
expect(ServersServiceMock.createServer.firstCall.calledWith(serverToCreate)).toEqual(true); expect(ServersServiceMock.createServer.firstCall.calledWith(serverToCreate)).toEqual(true);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0); expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.createServers.callCount).toEqual(0); expect(ServersServiceMock.deleteServer.called).toEqual(false);
expect(ServersServiceMock.createServers.called).toEqual(false);
}); });
}); });
describe('deleteServer', () => { describe('deleteServer', () => {
it('deletes a server and then fetches servers again', () => { it('deletes a server and then fetches servers again', () => {
const serverToDelete = { id: 'abc123' }; const serverToDelete = { id: 'abc123' };
const result = deleteServer(ServersServiceMock)(serverToDelete); const result = deleteServer(ServersServiceMock, () => expectedFetchServersResult)(serverToDelete);
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.createServer.callCount).toEqual(0); expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.createServers.callCount).toEqual(0); expect(ServersServiceMock.createServers.called).toEqual(false);
expect(ServersServiceMock.deleteServer.callCount).toEqual(1); expect(ServersServiceMock.deleteServer.calledOnce).toEqual(true);
expect(ServersServiceMock.deleteServer.firstCall.calledWith(serverToDelete)).toEqual(true); expect(ServersServiceMock.deleteServer.firstCall.calledWith(serverToDelete)).toEqual(true);
}); });
}); });
@ -79,14 +80,14 @@ describe('serverReducer', () => {
describe('createServer', () => { describe('createServer', () => {
it('creates multiple servers and then fetches servers again', () => { it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(servers); const serversToCreate = values(servers);
const result = createServers(ServersServiceMock)(serversToCreate); const result = createServers(ServersServiceMock, () => expectedFetchServersResult)(serversToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.called).toEqual(false);
expect(ServersServiceMock.createServer.callCount).toEqual(0); expect(ServersServiceMock.createServer.called).toEqual(false);
expect(ServersServiceMock.createServers.callCount).toEqual(1); expect(ServersServiceMock.createServers.calledOnce).toEqual(true);
expect(ServersServiceMock.createServers.firstCall.calledWith(serversToCreate)).toEqual(true); expect(ServersServiceMock.createServers.firstCall.calledWith(serversToCreate)).toEqual(true);
expect(ServersServiceMock.deleteServer.callCount).toEqual(0); expect(ServersServiceMock.deleteServer.called).toEqual(false);
}); });
}); });
}); });

View file

@ -15,7 +15,7 @@ describe('<CreateShortUrl />', () => {
const createShortUrl = sinon.spy(); const createShortUrl = sinon.spy();
beforeEach(() => { beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector); const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => '');
wrapper = shallow( wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} /> <CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />

View file

@ -0,0 +1,108 @@
import React from 'react';
import { shallow } from 'enzyme';
import moment from 'moment';
import Moment from 'react-moment';
import { assoc, toString } from 'ramda';
import * as sinon from 'sinon';
import createShortUrlsRow from '../../../src/short-urls/helpers/ShortUrlsRow';
import ExternalLink from '../../../src/utils/ExternalLink';
import Tag from '../../../src/tags/helpers/Tag';
describe('<ShortUrlsRow />', () => {
let wrapper;
const mockFunction = () => '';
const ShortUrlsRowMenu = mockFunction;
const stateFlagTimeout = sinon.spy();
const colorGenerator = {
getColorForKey: mockFunction,
setColorForKey: mockFunction,
};
const server = {
url: 'https://doma.in',
};
const shortUrl = {
shortCode: 'abc123',
shortUrl: 'http://doma.in/abc123',
longUrl: 'http://foo.com/bar',
dateCreated: moment('2018-05-23 18:30:41').format(),
tags: [ 'nodejs', 'reactjs' ],
visitsCount: 45,
};
beforeEach(() => {
const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, stateFlagTimeout);
wrapper = shallow(
<ShortUrlsRow shortUrlsListParams={{}} refreshList={mockFunction} selecrtedServer={server} shortUrl={shortUrl} />
);
});
afterEach(() => wrapper.unmount());
it('renders date in first column', () => {
const col = wrapper.find('td').first();
const moment = col.find(Moment);
expect(moment.html()).toContain('>2018-05-23 18:30</time>');
});
it('renders short URL in second row', () => {
const col = wrapper.find('td').at(1);
const link = col.find(ExternalLink);
expect(link.prop('href')).toEqual(shortUrl.shortUrl);
});
it('renders long URL in third row', () => {
const col = wrapper.find('td').at(2); // eslint-disable-line no-magic-numbers
const link = col.find(ExternalLink);
expect(link.prop('href')).toEqual(shortUrl.longUrl);
});
describe('renders list of tags in fourth row', () => {
it('with tags', () => {
const col = wrapper.find('td').at(3); // eslint-disable-line no-magic-numbers
const tags = col.find(Tag);
expect(tags).toHaveLength(shortUrl.tags.length);
shortUrl.tags.forEach((tagValue, index) => {
const tag = tags.at(index);
expect(tag.prop('text')).toEqual(tagValue);
});
});
it('without tags', () => {
wrapper.setProps({ shortUrl: assoc('tags', [], shortUrl) });
const col = wrapper.find('td').at(3); // eslint-disable-line no-magic-numbers
expect(col.text()).toContain('No tags');
});
});
it('renders visits count in fifth row', () => {
const col = wrapper.find('td').at(4); // eslint-disable-line no-magic-numbers
expect(col.text()).toEqual(toString(shortUrl.visitsCount));
});
it('updates state when copied to clipboard', () => {
const col = wrapper.find('td').at(5); // eslint-disable-line no-magic-numbers
const menu = col.find(ShortUrlsRowMenu);
expect(menu).toHaveLength(1);
expect(stateFlagTimeout.called).toEqual(false);
menu.simulate('copyToClipboard');
expect(stateFlagTimeout.calledOnce).toEqual(true);
});
it('shows copy hint when state prop is true', () => {
// eslint-disable-next-line no-magic-numbers
const isHidden = () => wrapper.find('td').at(5).find('.short-urls-row__copy-hint').prop('hidden');
expect(isHidden()).toEqual(true);
wrapper.setState({ copiedToClipboard: true });
expect(isHidden()).toEqual(false);
});
});

View file

@ -0,0 +1,79 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import { ButtonDropdown, DropdownItem } from 'reactstrap';
import createShortUrlsRowMenu from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
describe('<ShortUrlsRowMenu />', () => {
let wrapper;
const DeleteShortUrlModal = () => '';
const EditTagsModal = () => '';
const onCopyToClipboard = sinon.spy();
const selectedServer = { id: 'abc123' };
const shortUrl = {
shortCode: 'abc123',
shortUrl: 'https://doma.in/abc123',
};
beforeEach(() => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal);
wrapper = shallow(
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} onCopyToClipboard={onCopyToClipboard} />
);
});
afterEach(() => wrapper.unmount());
it('renders modal windows', () => {
const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal);
const editTagsModal = wrapper.find(EditTagsModal);
const previewModal = wrapper.find(PreviewModal);
const qrCodeModal = wrapper.find(QrCodeModal);
expect(deleteShortUrlModal).toHaveLength(1);
expect(editTagsModal).toHaveLength(1);
expect(previewModal).toHaveLength(1);
expect(qrCodeModal).toHaveLength(1);
});
it('renders correct amount of menu items', () => {
const items = wrapper.find(DropdownItem);
const expectedNonDividerItems = 6;
const expectedDividerItems = 2;
expect(items).toHaveLength(expectedNonDividerItems + expectedDividerItems);
expect(items.find('[divider]')).toHaveLength(expectedDividerItems);
});
describe('toggles state when toggling modal windows', () => {
const assert = (modalComponent, stateProp, done) => {
const modal = wrapper.find(modalComponent);
expect(wrapper.state(stateProp)).toEqual(false);
modal.prop('toggle')();
setImmediate(() => {
expect(wrapper.state(stateProp)).toEqual(true);
done();
});
};
it('DeleteShortUrlModal', (done) => assert(DeleteShortUrlModal, 'isDeleteModalOpen', done));
it('EditTagsModal', (done) => assert(EditTagsModal, 'isTagsModalOpen', done));
it('PreviewModal', (done) => assert(PreviewModal, 'isPreviewModalOpen', done));
it('QrCodeModal', (done) => assert(QrCodeModal, 'isQrModalOpen', done));
});
it('toggles dropdown state when toggling dropdown', (done) => {
const dropdown = wrapper.find(ButtonDropdown);
expect(wrapper.state('isOpen')).toEqual(false);
dropdown.prop('toggle')();
setImmediate(() => {
expect(wrapper.state('isOpen')).toEqual(true);
done();
});
});
});

View file

@ -0,0 +1,87 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as sinon from 'sinon';
import { Modal, ModalBody, ModalFooter } from 'reactstrap';
import DeleteTagConfirmModal from '../../../src/tags/helpers/DeleteTagConfirmModal';
describe('<DeleteTagConfirmModal />', () => {
let wrapper;
const tag = 'nodejs';
const deleteTag = sinon.spy();
const tagDeleted = sinon.spy();
const createWrapper = (tagDelete) => {
wrapper = shallow(
<DeleteTagConfirmModal
tag={tag}
toggle={() => ''}
isOpen
deleteTag={deleteTag}
tagDeleted={tagDeleted}
tagDelete={tagDelete}
/>
);
return wrapper;
};
afterEach(() => {
wrapper && wrapper.unmount();
deleteTag.resetHistory();
tagDeleted.resetHistory();
});
it('asks confirmation for provided tag to be deleted', () => {
wrapper = createWrapper({ error: false, deleting: false });
const body = wrapper.find(ModalBody);
const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger');
expect(body.html()).toContain(`Are you sure you want to delete tag <b>${tag}</b>?`);
expect(delBtn.prop('disabled')).toEqual(false);
expect(delBtn.text()).toEqual('Delete tag');
});
it('shows error message when deletion failed', () => {
wrapper = createWrapper({ error: true, deleting: false });
const body = wrapper.find(ModalBody);
expect(body.html()).toContain('Something went wrong while deleting the tag :(');
});
it('shows loading status while deleting', () => {
wrapper = createWrapper({ error: false, deleting: true });
const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger');
expect(delBtn.prop('disabled')).toEqual(true);
expect(delBtn.text()).toEqual('Deleting tag...');
});
it('deletes tag modal when btn is clicked', () => {
wrapper = createWrapper({ error: false, deleting: true });
const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger');
delBtn.simulate('click');
expect(deleteTag.calledOnce).toEqual(true);
expect(deleteTag.calledWith(tag)).toEqual(true);
});
it('does no further actions when modal is closed without deleting tag', () => {
wrapper = createWrapper({ error: false, deleting: false });
const modal = wrapper.find(Modal);
modal.simulate('closed');
expect(tagDeleted.called).toEqual(false);
});
it('notifies tag to be deleted when modal is closed after deleting tag', () => {
wrapper = createWrapper({ error: false, deleting: false });
const modal = wrapper.find(Modal);
wrapper.instance().tagWasDeleted = true;
modal.simulate('closed');
expect(tagDeleted.calledOnce).toEqual(true);
expect(tagDeleted.calledWith(tag)).toEqual(true);
});
});

View file

@ -0,0 +1,78 @@
import React from 'react';
import { shallow } from 'enzyme';
import { keys, values } from 'ramda';
import SortableBarGraph from '../../src/visits/SortableBarGraph';
import GraphCard from '../../src/visits/GraphCard';
import SortingDropdown from '../../src/utils/SortingDropdown';
describe('<SortableBarGraph />', () => {
let wrapper;
const sortingItems = {
name: 'Name',
amount: 'Amount',
};
const stats = {
Foo: 100,
Bar: 50,
};
const createWrapper = (extraHeaderContent = []) => {
wrapper = shallow(
<SortableBarGraph title="Foo" stats={stats} sortingItems={sortingItems} extraHeaderContent={extraHeaderContent} />
);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it('renders stats unchanged when no ordering is set', () => {
const wrapper = createWrapper();
const graphCard = wrapper.find(GraphCard);
expect(graphCard.prop('stats')).toEqual(stats);
});
describe('renders properly ordered stats when ordering is set', () => {
let assert;
beforeEach(() => {
const wrapper = createWrapper();
const dropdown = wrapper.find(SortingDropdown);
assert = (sortName, sortDir, expectedKeys, expectedValues, done) => {
dropdown.prop('onChange')(sortName, sortDir);
setImmediate(() => {
const graphCard = wrapper.find(GraphCard);
const statsKeys = keys(graphCard.prop('stats'));
const statsValues = values(graphCard.prop('stats'));
expect(statsKeys).toEqual(expectedKeys);
expect(statsValues).toEqual(expectedValues);
done();
});
};
});
// eslint-disable-next-line no-magic-numbers
it('name - ASC', (done) => assert('name', 'ASC', [ 'Bar', 'Foo' ], [ 50, 100 ], done));
// eslint-disable-next-line no-magic-numbers
it('name - DESC', (done) => assert('name', 'DESC', [ 'Foo', 'Bar' ], [ 100, 50 ], done));
// eslint-disable-next-line no-magic-numbers
it('value - ASC', (done) => assert('value', 'ASC', [ 'Bar', 'Foo' ], [ 50, 100 ], done));
// eslint-disable-next-line no-magic-numbers
it('value - DESC', (done) => assert('value', 'DESC', [ 'Foo', 'Bar' ], [ 100, 50 ], done));
});
it('renders extra header functions', () => {
const wrapper = createWrapper([
() => <span className="foo-span">Foo</span>,
() => <span className="bar-span">Bar</span>,
]);
expect(wrapper.find('.foo-span')).toHaveLength(1);
expect(wrapper.find('.bar-span')).toHaveLength(1);
});
});

View file

@ -8,6 +8,7 @@ describe('<VisitsHeader />', () => {
let wrapper; let wrapper;
const shortUrlDetail = { const shortUrlDetail = {
shortUrl: { shortUrl: {
shortUrl: 'https://doma.in/abc123',
longUrl: 'https://foo.bar/bar/foo', longUrl: 'https://foo.bar/bar/foo',
dateCreated: '2018-01-01T10:00:00+01:00', dateCreated: '2018-01-01T10:00:00+01:00',
}, },