Merge pull request #70 from acelaya/feature/tests

Feature/tests
This commit is contained in:
Alejandro Celaya 2018-11-01 15:02:09 +01:00 committed by GitHub
commit 591c3b76f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 890 additions and 178 deletions

View file

@ -1,37 +0,0 @@
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import DatePicker from 'react-datepicker';
import { isNil } from 'ramda';
import './DateInput.scss';
export default class DateInput extends React.Component {
constructor(props) {
super(props);
this.inputRef = props.ref || React.createRef();
}
render() {
const { className, isClearable, selected } = this.props;
const showCalendarIcon = !isClearable || isNil(selected);
return (
<div className="date-input-container">
<DatePicker
{...this.props}
className={`date-input-container__input form-control ${className || ''}`}
dateFormat="YYYY-MM-DD"
readOnly
ref={this.inputRef}
/>
{showCalendarIcon && (
<FontAwesomeIcon
icon={calendarIcon}
className="date-input-container__icon"
onClick={() => this.inputRef.current.input.focus()}
/>
)}
</div>
);
}
}

View file

@ -5,12 +5,22 @@ import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Collapse } from 'reactstrap'; import { Collapse } from 'reactstrap';
import DateInput from '../common/DateInput'; import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import TagsSelector from '../tags/helpers/TagsSelector'; import TagsSelector from '../tags/helpers/TagsSelector';
import CreateShortUrlResult from './helpers/CreateShortUrlResult'; import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreation'; import { createShortUrl, createShortUrlResultType, resetCreateShortUrl } from './reducers/shortUrlCreation';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
export class CreateShortUrlComponent extends React.Component { export class CreateShortUrlComponent extends React.Component {
static propTypes = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
};
state = { state = {
longUrl: '', longUrl: '',
tags: [], tags: [],
@ -24,27 +34,31 @@ export class CreateShortUrlComponent extends React.Component {
render() { render() {
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props; const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
const changeTags = (tags) => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) }); const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => ( const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<input <div className="form-group">
className="form-control" <input
type={type} className="form-control"
placeholder={placeholder} id={id}
value={this.state[id]} type={type}
onChange={(e) => this.setState({ [id]: e.target.value })} placeholder={placeholder}
{...props} value={this.state[id]}
/> onChange={(e) => this.setState({ [id]: e.target.value })}
{...props}
/>
</div>
); );
const createDateInput = (id, placeholder, props = {}) => ( const renderDateInput = (id, placeholder, props = {}) => (
<DateInput <div className="form-group">
selected={this.state[id]} <DateInput
placeholderText={placeholder} selected={this.state[id]}
isClearable placeholderText={placeholder}
onChange={(date) => this.setState({ [id]: date })} isClearable
{...props} onChange={(date) => this.setState({ [id]: date })}
/> {...props}
/>
</div>
); );
const formatDate = (date) => isNil(date) ? date : date.format();
const save = (e) => { const save = (e) => {
e.preventDefault(); e.preventDefault();
createShortUrl(pipe( createShortUrl(pipe(
@ -75,20 +89,12 @@ export class CreateShortUrlComponent extends React.Component {
<div className="row"> <div className="row">
<div className="col-sm-6"> <div className="col-sm-6">
<div className="form-group"> {renderOptionalInput('customSlug', 'Custom slug')}
{renderOptionalInput('customSlug', 'Custom slug')} {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="form-group">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
</div> </div>
<div className="col-sm-6"> <div className="col-sm-6">
<div className="form-group"> {renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
{createDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })} {renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
<div className="form-group">
{createDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div> </div>
</div> </div>
</Collapse> </Collapse>

View file

@ -115,23 +115,17 @@ export class ShortUrlsListComponent extends React.Component {
)); ));
} }
renderMobileOrderingControls() {
return (
<div className="d-block d-md-none mb-3">
<SortingDropdown
items={SORTABLE_FIELDS}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
onChange={this.handleOrderBy}
/>
</div>
);
}
render() { render() {
return ( return (
<React.Fragment> <React.Fragment>
{this.renderMobileOrderingControls()} <div className="d-block d-md-none mb-3">
<SortingDropdown
items={SORTABLE_FIELDS}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
onChange={this.handleOrderBy}
/>
</div>
<table className="table table-striped table-hover"> <table className="table table-striped table-hover">
<thead className="short-urls-list__header"> <thead className="short-urls-list__header">
<tr> <tr>

View file

@ -47,6 +47,8 @@ export class DeleteShortUrlModalComponent extends Component {
render() { render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props; const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION'; const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered> <Modal isOpen={isOpen} toggle={toggle} centered>
@ -66,12 +68,12 @@ export class DeleteShortUrlModalComponent extends Component {
onChange={(e) => this.setState({ inputValue: e.target.value })} onChange={(e) => this.setState({ inputValue: e.target.value })}
/> />
{shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED && ( {hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center"> <div className="p-2 mt-2 bg-warning text-center">
This short URL has received too many visits and therefore, it cannot be deleted This short URL has received too many visits and therefore, it cannot be deleted
</div> </div>
)} )}
{shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED && ( {hasErrorOtherThanThreshold && (
<div className="p-2 mt-2 bg-danger text-white text-center"> <div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :( Something went wrong while deleting the URL :(
</div> </div>

View file

@ -10,20 +10,20 @@ const propTypes = {
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
}; };
export default function PreviewModal({ url, toggle, isOpen }) { const PreviewModal = ({ url, toggle, isOpen }) => (
return ( <Modal isOpen={isOpen} toggle={toggle} size="lg">
<Modal isOpen={isOpen} toggle={toggle} size="lg"> <ModalHeader toggle={toggle}>
<ModalHeader toggle={toggle}> Preview for <ExternalLink href={url}>{url}</ExternalLink>
Preview for <ExternalLink href={url}>{url}</ExternalLink> </ModalHeader>
</ModalHeader> <ModalBody>
<ModalBody> <div className="text-center">
<div className="text-center"> <p className="preview-modal__loader">Loading...</p>
<p className="preview-modal__loader">Loading...</p> <img src={`${url}/preview`} className="preview-modal__img" alt="Preview" />
<img src={`${url}/preview`} className="preview-modal__img" alt="Preview" /> </div>
</div> </ModalBody>
</ModalBody> </Modal>
</Modal> );
);
}
PreviewModal.propTypes = propTypes; PreviewModal.propTypes = propTypes;
export default PreviewModal;

View file

@ -10,19 +10,19 @@ const propTypes = {
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
}; };
export default function QrCodeModal({ url, toggle, isOpen }) { const QrCodeModal = ({ url, toggle, isOpen }) => (
return ( <Modal isOpen={isOpen} toggle={toggle} centered>
<Modal isOpen={isOpen} toggle={toggle} centered> <ModalHeader toggle={toggle}>
<ModalHeader toggle={toggle}> QR code for <ExternalLink href={url}>{url}</ExternalLink>
QR code for <ExternalLink href={url}>{url}</ExternalLink> </ModalHeader>
</ModalHeader> <ModalBody>
<ModalBody> <div className="text-center">
<div className="text-center"> <img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" />
<img src={`${url}/qr-code`} className="qr-code-modal__img" alt="QR code" /> </div>
</div> </ModalBody>
</ModalBody> </Modal>
</Modal> );
);
}
QrCodeModal.propTypes = propTypes; QrCodeModal.propTypes = propTypes;
export default QrCodeModal;

View file

@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient'; import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR'; export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL'; export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
/* eslint-enable padding-line-between-statements, newline-after-var */ /* eslint-enable padding-line-between-statements, newline-after-var */
export const createShortUrlResultType = PropTypes.shape({ export const createShortUrlResultType = PropTypes.shape({
@ -29,6 +29,7 @@ export default function reducer(state = defaultState, action) {
return { return {
...state, ...state,
saving: true, saving: true,
error: false,
}; };
case CREATE_SHORT_URL_ERROR: case CREATE_SHORT_URL_ERROR:
return { return {

View file

@ -28,36 +28,20 @@ export default class TagCard extends React.Component {
return ( return (
<Card className="tag-card"> <Card className="tag-card">
<CardBody className="tag-card__body"> <CardBody className="tag-card__body">
<button <button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
className="btn btn-light btn-sm tag-card__btn tag-card__btn--last"
onClick={toggleDelete}
>
<FontAwesomeIcon icon={deleteIcon} /> <FontAwesomeIcon icon={deleteIcon} />
</button> </button>
<button <button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
className="btn btn-light btn-sm tag-card__btn"
onClick={toggleEdit}
>
<FontAwesomeIcon icon={editIcon} /> <FontAwesomeIcon icon={editIcon} />
</button> </button>
<h5 className="tag-card__tag-title"> <h5 className="tag-card__tag-title">
<TagBullet tag={tag} /> <TagBullet tag={tag} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}> <Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
{tag}
</Link>
</h5> </h5>
</CardBody> </CardBody>
<DeleteTagConfirmModal <DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
tag={tag} <EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
toggle={toggleDelete}
isOpen={this.state.isDeleteModalOpen}
/>
<EditTagModal
tag={tag}
toggle={toggleEdit}
isOpen={this.state.isEditModalOpen}
/>
</Card> </Card>
); );
} }

View file

@ -8,7 +8,7 @@ import { filterTags, forceListTags } from './reducers/tagsList';
import TagCard from './TagCard'; import TagCard from './TagCard';
const { ceil } = Math; const { ceil } = Math;
const TAGS_GROUP_SIZE = 4; const TAGS_GROUPS_AMOUNT = 4;
export class TagsListComponent extends React.Component { export class TagsListComponent extends React.Component {
static propTypes = { static propTypes = {
@ -16,6 +16,8 @@ export class TagsListComponent extends React.Component {
forceListTags: PropTypes.func, forceListTags: PropTypes.func,
tagsList: PropTypes.shape({ tagsList: PropTypes.shape({
loading: PropTypes.bool, loading: PropTypes.bool,
error: PropTypes.bool,
filteredTags: PropTypes.arrayOf(PropTypes.string),
}), }),
match: PropTypes.object, match: PropTypes.object,
}; };
@ -23,7 +25,7 @@ export class TagsListComponent extends React.Component {
componentDidMount() { componentDidMount() {
const { forceListTags } = this.props; const { forceListTags } = this.props;
forceListTags(true); forceListTags();
} }
renderContent() { renderContent() {
@ -47,7 +49,7 @@ export class TagsListComponent extends React.Component {
return <MuttedMessage>No tags found</MuttedMessage>; return <MuttedMessage>No tags found</MuttedMessage>;
} }
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUP_SIZE), tagsList.filteredTags); const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
return ( return (
<React.Fragment> <React.Fragment>
@ -71,13 +73,9 @@ export class TagsListComponent extends React.Component {
return ( return (
<div className="shlink-container"> <div className="shlink-container">
{!this.props.tagsList.loading && ( {!this.props.tagsList.loading &&
<SearchField <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
className="mb-3" }
placeholder="Search tags..."
onChange={filterTags}
/>
)}
<div className="row"> <div className="row">
{this.renderContent()} {this.renderContent()}
</div> </div>

View file

@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient'; import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START'; export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR'; export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG'; export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED'; export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements, newline-after-var */ /* eslint-enable padding-line-between-statements, newline-after-var */

View file

@ -3,9 +3,9 @@ import shlinkApiClient from '../../api/ShlinkApiClient';
import colorGenerator from '../../utils/ColorGenerator'; import colorGenerator from '../../utils/ColorGenerator';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START'; export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR'; export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
const EDIT_TAG = 'shlink/editTag/EDIT_TAG'; export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
/* eslint-enable padding-line-between-statements, newline-after-var */ /* eslint-enable padding-line-between-statements, newline-after-var */
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED'; export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
@ -42,20 +42,19 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) => export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) => async (dispatch) => {
async (dispatch) => { dispatch({ type: EDIT_TAG_START });
dispatch({ type: EDIT_TAG_START });
try { try {
await shlinkApiClient.editTag(oldName, newName); await shlinkApiClient.editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color); colorGenerator.setColorForKey(newName, color);
dispatch({ type: EDIT_TAG, oldName, newName }); dispatch({ type: EDIT_TAG, oldName, newName });
} catch (e) { } catch (e) {
dispatch({ type: EDIT_TAG_ERROR }); dispatch({ type: EDIT_TAG_ERROR });
throw e; throw e;
} }
}; };
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator); export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);

42
src/utils/DateInput.js Normal file
View file

@ -0,0 +1,42 @@
import React from 'react';
import { isNil } from 'ramda';
import DatePicker from 'react-datepicker';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
import * as PropTypes from 'prop-types';
import './DateInput.scss';
const propTypes = {
className: PropTypes.string,
isClearable: PropTypes.bool,
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
ref: PropTypes.object,
};
const DateInput = (props) => {
const { className, isClearable, selected, ref = React.createRef() } = props;
const showCalendarIcon = !isClearable || isNil(selected);
return (
<div className="date-input-container">
<DatePicker
{...props}
className={`date-input-container__input form-control ${className || ''}`}
dateFormat="YYYY-MM-DD"
readOnly
ref={ref}
/>
{showCalendarIcon && (
<FontAwesomeIcon
icon={calendarIcon}
className="date-input-container__icon"
onClick={() => ref.current.input.focus()}
/>
)}
</div>
);
};
DateInput.propTypes = propTypes;
export default DateInput;

View file

@ -1,5 +1,5 @@
@import '../utils/mixins/vertical-align'; @import './mixins/vertical-align';
@import '../utils/base'; @import './base';
.date-input-container { .date-input-container {
position: relative; position: relative;

View file

@ -5,7 +5,7 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import DateInput from '../common/DateInput'; import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage'; import MutedMessage from '../utils/MuttedMessage';
import SortableBarGraph from './SortableBarGraph'; import SortableBarGraph from './SortableBarGraph';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';

View file

@ -0,0 +1,66 @@
import React from 'react';
import { shallow } from 'enzyme';
import moment from 'moment';
import * as sinon from 'sinon';
import { identity } from 'ramda';
import { CreateShortUrlComponent as CreateShortUrl } from '../../src/short-urls/CreateShortUrl';
import TagsSelector from '../../src/tags/helpers/TagsSelector';
import DateInput from '../../src/utils/DateInput';
describe('<CreateShortUrl />', () => {
let wrapper;
const shortUrlCreationResult = {
loading: false,
};
const createShortUrl = sinon.spy();
beforeEach(() => {
wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />
);
});
afterEach(() => {
wrapper.unmount();
createShortUrl.resetHistory();
});
it('saves short URL with data set in form controls', (done) => {
const validSince = moment('2017-01-01');
const validUntil = moment('2017-01-06');
const urlInput = wrapper.find('.form-control-lg');
const tagsInput = wrapper.find(TagsSelector);
const customSlugInput = wrapper.find('#customSlug');
const maxVisitsInput = wrapper.find('#maxVisits');
const dateInputs = wrapper.find(DateInput);
const validSinceInput = dateInputs.at(0);
const validUntilInput = dateInputs.at(1);
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
maxVisitsInput.simulate('change', { target: { value: '20' } });
validSinceInput.simulate('change', validSince);
validUntilInput.simulate('change', validUntil);
setImmediate(() => {
const form = wrapper.find('form');
form.simulate('submit', { preventDefault: identity });
expect(createShortUrl.callCount).toEqual(1);
expect(createShortUrl.getCall(0).args).toEqual(
[
{
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
},
]
);
done();
});
});
});

View file

@ -0,0 +1,26 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ShortUrlsComponent as ShortUrls } from '../../src/short-urls/ShortUrls';
import Paginator from '../../src/short-urls/Paginator';
import ShortUrlsList from '../../src/short-urls/ShortUrlsList';
import SearchBar from '../../src/short-urls/SearchBar';
describe('<ShortUrlsList />', () => {
let wrapper;
beforeEach(() => {
const params = {
serverId: '1',
page: '1',
};
wrapper = shallow(<ShortUrls match={{ params }} shortUrlsList={{ data: [] }} />);
});
afterEach(() => wrapper.unmount());
it('wraps a SearchBar, ShortUrlsList as Paginator', () => {
expect(wrapper.find(SearchBar)).toHaveLength(1);
expect(wrapper.find(ShortUrlsList)).toHaveLength(1);
expect(wrapper.find(Paginator)).toHaveLength(1);
});
});

View file

@ -0,0 +1,48 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap';
import CreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
describe('<CreateShortUrlResult />', () => {
let wrapper;
const createWrapper = (result, error = false) => {
wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it('renders an error when error is true', () => {
const wrapper = createWrapper({}, true);
const errorCard = wrapper.find('.bg-danger');
expect(errorCard).toHaveLength(1);
expect(errorCard.html()).toContain('An error occurred while creating the URL :(');
});
it('renders nothing when no result is provided', () => {
const wrapper = createWrapper();
expect(wrapper.html()).toBeNull();
});
it('renders a result message when result is provided', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
expect(wrapper.html()).toContain('<b>Great!</b> The short URL is <b>https://doma.in/abc123</b>');
expect(wrapper.find(CopyToClipboard)).toHaveLength(1);
expect(wrapper.find(Tooltip)).toHaveLength(1);
});
it('Shows tooltip when copy to clipboard button is clicked', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
const copyBtn = wrapper.find(CopyToClipboard);
expect(wrapper.state('showCopyTooltip')).toEqual(false);
copyBtn.simulate('copy');
expect(wrapper.state('showCopyTooltip')).toEqual(true);
});
});

View file

@ -0,0 +1,115 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import * as sinon from 'sinon';
import { DeleteShortUrlModalComponent as DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal';
describe('<DeleteShortUrlModal />', () => {
let wrapper;
const shortUrl = {
tags: [],
shortCode: 'abc123',
originalUrl: 'https://long-domain.com/foo/bar',
};
const deleteShortUrl = sinon.fake.returns(Promise.resolve());
const createWrapper = (shortUrlDeletion) => {
wrapper = shallow(
<DeleteShortUrlModal
isOpen
shortUrl={shortUrl}
shortUrlDeletion={shortUrlDeletion}
toggle={identity}
deleteShortUrl={deleteShortUrl}
resetDeleteShortUrl={identity}
shortUrlDeleted={identity}
/>
);
return wrapper;
};
afterEach(() => {
wrapper && wrapper.unmount();
deleteShortUrl.resetHistory();
});
it('shows threshold error message when threshold error occurs', () => {
const wrapper = createWrapper({
loading: false,
error: true,
shortCode: 'abc123',
errorData: { error: 'INVALID_SHORTCODE_DELETION' },
});
const warning = wrapper.find('.bg-warning');
expect(warning).toHaveLength(1);
expect(warning.html()).toContain('This short URL has received too many visits and therefore, it cannot be deleted');
});
it('shows generic error when non-threshold error occurs', () => {
const wrapper = createWrapper({
loading: false,
error: true,
shortCode: 'abc123',
errorData: { error: 'OTHER_ERROR' },
});
const error = wrapper.find('.bg-danger');
expect(error).toHaveLength(1);
expect(error.html()).toContain('Something went wrong while deleting the URL :(');
});
it('disables submit button when loading', () => {
const wrapper = createWrapper({
loading: true,
error: false,
shortCode: 'abc123',
errorData: {},
});
const submit = wrapper.find('.btn-danger');
expect(submit).toHaveLength(1);
expect(submit.prop('disabled')).toEqual(true);
expect(submit.html()).toContain('Deleting...');
});
it('enables submit button when proper short code is provided', (done) => {
const shortCode = 'abc123';
const wrapper = createWrapper({
loading: false,
error: false,
shortCode,
errorData: {},
});
const input = wrapper.find('.form-control');
input.simulate('change', { target: { value: shortCode } });
setImmediate(() => {
const submit = wrapper.find('.btn-danger');
expect(submit.prop('disabled')).toEqual(false);
done();
});
});
it('tries to delete short URL when form is submit', (done) => {
const shortCode = 'abc123';
const wrapper = createWrapper({
loading: false,
error: false,
shortCode,
errorData: {},
});
const input = wrapper.find('.form-control');
input.simulate('change', { target: { value: shortCode } });
setImmediate(() => {
const form = wrapper.find('form');
expect(deleteShortUrl.callCount).toEqual(0);
form.simulate('submit', { preventDefault: identity });
expect(deleteShortUrl.callCount).toEqual(1);
done();
});
});
});

View file

@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
import ExternalLink from '../../../src/utils/ExternalLink';
describe('<PreviewModal />', () => {
let wrapper;
const url = 'https://doma.in/abc123';
beforeEach(() => {
wrapper = shallow(<PreviewModal url={url} />);
});
afterEach(() => wrapper.unmount());
it('shows an external link to the URL', () => {
const externalLink = wrapper.find(ExternalLink);
expect(externalLink).toHaveLength(1);
expect(externalLink.prop('href')).toEqual(url);
});
it('displays an image with the preview of the URL', () => {
const img = wrapper.find('img');
expect(img).toHaveLength(1);
expect(img.prop('src')).toEqual(`${url}/preview`);
});
});

View file

@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
import ExternalLink from '../../../src/utils/ExternalLink';
describe('<QrCodeModal />', () => {
let wrapper;
const url = 'https://doma.in/abc123';
beforeEach(() => {
wrapper = shallow(<QrCodeModal url={url} />);
});
afterEach(() => wrapper.unmount());
it('shows an external link to the URL', () => {
const externalLink = wrapper.find(ExternalLink);
expect(externalLink).toHaveLength(1);
expect(externalLink.prop('href')).toEqual(url);
});
it('displays an image with the QR code of the URL', () => {
const img = wrapper.find('img');
expect(img).toHaveLength(1);
expect(img.prop('src')).toEqual(`${url}/qr-code`);
});
});

View file

@ -0,0 +1,94 @@
import * as sinon from 'sinon';
import reducer, {
CREATE_SHORT_URL_START,
CREATE_SHORT_URL_ERROR,
CREATE_SHORT_URL,
RESET_CREATE_SHORT_URL,
_createShortUrl,
resetCreateShortUrl,
} from '../../../src/short-urls/reducers/shortUrlCreation';
describe('shortUrlCreationReducer', () => {
describe('reducer', () => {
it('returns loading on CREATE_SHORT_URL_START', () => {
expect(reducer({}, { type: CREATE_SHORT_URL_START })).toEqual({
saving: true,
error: false,
});
});
it('returns error on CREATE_SHORT_URL_ERROR', () => {
expect(reducer({}, { type: CREATE_SHORT_URL_ERROR })).toEqual({
saving: false,
error: true,
});
});
it('returns result on CREATE_SHORT_URL', () => {
expect(reducer({}, { type: CREATE_SHORT_URL, result: 'foo' })).toEqual({
saving: false,
error: false,
result: 'foo',
});
});
it('returns default state on RESET_CREATE_SHORT_URL', () => {
expect(reducer({}, { type: RESET_CREATE_SHORT_URL })).toEqual({
result: null,
saving: false,
error: false,
});
});
it('returns provided state on unknown action', () =>
expect(reducer({}, { type: 'unknown' })).toEqual({}));
});
describe('resetCreateShortUrl', () => {
it('returns proper action', () =>
expect(resetCreateShortUrl()).toEqual({ type: RESET_CREATE_SHORT_URL }));
});
describe('createShortUrl', () => {
const createApiClientMock = (result) => ({
createShortUrl: sinon.fake.returns(result),
});
const dispatch = sinon.spy();
afterEach(() => dispatch.resetHistory());
it('calls API on success', async () => {
const expectedDispatchCalls = 2;
const result = 'foo';
const apiClientMock = createApiClientMock(Promise.resolve(result));
const dispatchable = _createShortUrl(apiClientMock, {});
await dispatchable(dispatch);
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL, result }]);
});
it('throws on error', async () => {
const expectedDispatchCalls = 2;
const error = 'Error';
const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = _createShortUrl(apiClientMock, {});
try {
await dispatchable(dispatch);
} catch (e) {
expect(e).toEqual(error);
}
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL_ERROR }]);
});
});
});

46
test/tags/TagCard.test.js Normal file
View file

@ -0,0 +1,46 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Link } from 'react-router-dom';
import TagCard from '../../src/tags/TagCard';
import TagBullet from '../../src/tags/helpers/TagBullet';
describe('<TagCard />', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />);
});
afterEach(() => wrapper.unmount());
it('shows a TagBullet and a link to the list filtering by the tag', () => {
const link = wrapper.find(Link);
const bullet = wrapper.find(TagBullet);
expect(link.prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
expect(bullet.prop('tag')).toEqual('ssr');
});
it('displays delete modal when delete btn is clicked', (done) => {
const delBtn = wrapper.find('.tag-card__btn').at(0);
expect(wrapper.state('isDeleteModalOpen')).toEqual(false);
delBtn.simulate('click');
setImmediate(() => {
expect(wrapper.state('isDeleteModalOpen')).toEqual(true);
done();
});
});
it('displays edit modal when edit btn is clicked', (done) => {
const editBtn = wrapper.find('.tag-card__btn').at(1);
expect(wrapper.state('isEditModalOpen')).toEqual(false);
editBtn.simulate('click');
setImmediate(() => {
expect(wrapper.state('isEditModalOpen')).toEqual(true);
done();
});
});
});

View file

@ -0,0 +1,76 @@
import React from 'react';
import { shallow } from 'enzyme';
import { identity, range } from 'ramda';
import * as sinon from 'sinon';
import { TagsListComponent as TagsList } from '../../src/tags/TagsList';
import MuttedMessage from '../../src/utils/MuttedMessage';
import TagCard from '../../src/tags/TagCard';
import SearchField from '../../src/utils/SearchField';
describe('<TagsList />', () => {
let wrapper;
const filterTags = sinon.spy();
const createWrapper = (tagsList) => {
const params = { serverId: '1' };
wrapper = shallow(
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />
);
return wrapper;
};
afterEach(() => {
wrapper && wrapper.unmount();
filterTags.resetHistory();
});
it('shows a loading message when tags are being loaded', () => {
const wrapper = createWrapper({ loading: true });
const loadingMsg = wrapper.find(MuttedMessage);
expect(loadingMsg).toHaveLength(1);
expect(loadingMsg.html()).toContain('Loading...');
});
it('shows an error when tags failed to be loaded', () => {
const wrapper = createWrapper({ error: true });
const errorMsg = wrapper.find('.bg-danger');
expect(errorMsg).toHaveLength(1);
expect(errorMsg.html()).toContain('Error loading tags :(');
});
it('shows a message when the list of tags is empty', () => {
const wrapper = createWrapper({ filteredTags: [] });
const msg = wrapper.find(MuttedMessage);
expect(msg).toHaveLength(1);
expect(msg.html()).toContain('No tags found');
});
it('renders the proper amount of groups and cards based on the amount of tags', () => {
const amountOfTags = 10;
const amountOfGroups = 4;
const wrapper = createWrapper({ filteredTags: range(0, amountOfTags).map((i) => `tag_${i}`) });
const cards = wrapper.find(TagCard);
const groups = wrapper.find('.col-md-6');
expect(cards).toHaveLength(amountOfTags);
expect(groups).toHaveLength(amountOfGroups);
});
it('triggers tags filtering when search field changes', (done) => {
const wrapper = createWrapper({ filteredTags: [] });
const searchField = wrapper.find(SearchField);
expect(searchField).toHaveLength(1);
expect(filterTags.callCount).toEqual(0);
searchField.simulate('change');
setImmediate(() => {
expect(filterTags.callCount).toEqual(1);
done();
});
});
});

View file

@ -0,0 +1,91 @@
import * as sinon from 'sinon';
import reducer, {
DELETE_TAG_START,
DELETE_TAG_ERROR,
DELETE_TAG,
TAG_DELETED,
tagDeleted,
_deleteTag,
} from '../../../src/tags/reducers/tagDelete';
describe('tagDeleteReducer', () => {
describe('reducer', () => {
it('returns loading on DELETE_TAG_START', () => {
expect(reducer({}, { type: DELETE_TAG_START })).toEqual({
deleting: true,
error: false,
});
});
it('returns error on DELETE_TAG_ERROR', () => {
expect(reducer({}, { type: DELETE_TAG_ERROR })).toEqual({
deleting: false,
error: true,
});
});
it('returns tag names on DELETE_TAG', () => {
expect(reducer({}, { type: DELETE_TAG })).toEqual({
deleting: false,
error: false,
});
});
it('returns provided state on unknown action', () =>
expect(reducer({}, { type: 'unknown' })).toEqual({}));
});
describe('tagDeleted', () => {
it('returns action based on provided params', () =>
expect(tagDeleted('foo')).toEqual({
type: TAG_DELETED,
tag: 'foo',
}));
});
describe('deleteTag', () => {
const createApiClientMock = (result) => ({
deleteTags: sinon.fake.returns(result),
});
const dispatch = sinon.spy();
afterEach(() => dispatch.resetHistory());
it('calls API on success', async () => {
const expectedDispatchCalls = 2;
const tag = 'foo';
const apiClientMock = createApiClientMock(Promise.resolve());
const dispatchable = _deleteTag(apiClientMock, tag);
await dispatchable(dispatch);
expect(apiClientMock.deleteTags.callCount).toEqual(1);
expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG }]);
});
it('throws on error', async () => {
const expectedDispatchCalls = 2;
const error = 'Error';
const tag = 'foo';
const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = _deleteTag(apiClientMock, tag);
try {
await dispatchable(dispatch);
} catch (e) {
expect(e).toEqual(error);
}
expect(apiClientMock.deleteTags.callCount).toEqual(1);
expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG_ERROR }]);
});
});
});

View file

@ -0,0 +1,110 @@
import * as sinon from 'sinon';
import reducer, {
EDIT_TAG_START,
EDIT_TAG_ERROR,
EDIT_TAG,
TAG_EDITED,
tagEdited,
_editTag,
} from '../../../src/tags/reducers/tagEdit';
describe('tagEditReducer', () => {
describe('reducer', () => {
it('returns loading on EDIT_TAG_START', () => {
expect(reducer({}, { type: EDIT_TAG_START })).toEqual({
editing: true,
error: false,
});
});
it('returns error on EDIT_TAG_ERROR', () => {
expect(reducer({}, { type: EDIT_TAG_ERROR })).toEqual({
editing: false,
error: true,
});
});
it('returns tag names on EDIT_TAG', () => {
expect(reducer({}, { type: EDIT_TAG, oldName: 'foo', newName: 'bar' })).toEqual({
editing: false,
error: false,
oldName: 'foo',
newName: 'bar',
});
});
it('returns provided state on unknown action', () =>
expect(reducer({}, { type: 'unknown' })).toEqual({}));
});
describe('tagEdited', () => {
it('returns action based on provided params', () =>
expect(tagEdited('foo', 'bar', '#ff0000')).toEqual({
type: TAG_EDITED,
oldName: 'foo',
newName: 'bar',
color: '#ff0000',
}));
});
describe('editTag', () => {
const createApiClientMock = (result) => ({
editTag: sinon.fake.returns(result),
});
const colorGenerator = {
setColorForKey: sinon.spy(),
};
const dispatch = sinon.spy();
afterEach(() => {
colorGenerator.setColorForKey.resetHistory();
dispatch.resetHistory();
});
it('calls API on success', async () => {
const expectedDispatchCalls = 2;
const oldName = 'foo';
const newName = 'bar';
const color = '#ff0000';
const apiClientMock = createApiClientMock(Promise.resolve());
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
await dispatchable(dispatch);
expect(apiClientMock.editTag.callCount).toEqual(1);
expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
expect(colorGenerator.setColorForKey.callCount).toEqual(1);
expect(colorGenerator.setColorForKey.getCall(0).args).toEqual([ newName, color ]);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG, oldName, newName }]);
});
it('throws on error', async () => {
const expectedDispatchCalls = 2;
const error = 'Error';
const oldName = 'foo';
const newName = 'bar';
const color = '#ff0000';
const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
try {
await dispatchable(dispatch);
} catch (e) {
expect(e).toEqual(error);
}
expect(apiClientMock.editTag.callCount).toEqual(1);
expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
expect(colorGenerator.setColorForKey.callCount).toEqual(0);
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG_ERROR }]);
});
});
});

View file

@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import moment from 'moment'; import moment from 'moment';
import DateInput from '../../src/common/DateInput'; import DateInput from '../../src/utils/DateInput';
describe('<DateInput />', () => { describe('<DateInput />', () => {
let wrapped; let wrapped;
@ -13,12 +13,7 @@ describe('<DateInput />', () => {
return wrapped; return wrapped;
}; };
afterEach(() => { afterEach(() => wrapped && wrapped.unmount());
if (wrapped !== undefined) {
wrapped.unmount();
wrapped = undefined;
}
});
it('wrapps a DatePicker', () => { it('wrapps a DatePicker', () => {
wrapped = createComponent(); wrapped = createComponent();

View file

@ -6,7 +6,7 @@ import * as sinon from 'sinon';
import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits'; import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits';
import MutedMessage from '../../src/utils/MuttedMessage'; import MutedMessage from '../../src/utils/MuttedMessage';
import GraphCard from '../../src/visits/GraphCard'; import GraphCard from '../../src/visits/GraphCard';
import DateInput from '../../src/common/DateInput'; import DateInput from '../../src/utils/DateInput';
import SortableBarGraph from '../../src/visits/SortableBarGraph'; import SortableBarGraph from '../../src/visits/SortableBarGraph';
describe('<ShortUrlVisits />', () => { describe('<ShortUrlVisits />', () => {