소스 검색

Merge branch 'develop' of github.com:thedevs-network/kutt into develop

Pouria Ezzati 7 년 전
부모
커밋
f3fb97a7c8

+ 2 - 1
.eslintignore

@@ -1,3 +1,4 @@
 .next/
 flow-typed/
-node_modules/
+node_modules/
+client/**/__test__/

+ 4 - 3
.travis.yml

@@ -1,12 +1,13 @@
 language: node_js
 
 node_js:
-  - "8"
+  - "9"
 
 before_install:
   - cp ./server/config.example.js ./server/config.js
   - cp ./client/config.example.js ./client/config.js
 
 script:
-  - npm run lint:nofix
-  - npm run build
+  - yarn run lint:nofix
+  - yarn test
+  - yarn build

+ 161 - 0
client/actions/__test__/auth.js

@@ -0,0 +1,161 @@
+import nock from 'nock';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import cookie from 'js-cookie';
+import thunk from 'redux-thunk';
+import Router from 'next/router';
+import configureMockStore from 'redux-mock-store';
+
+import { signupUser, loginUser, logoutUser, renewAuthUser } from '../auth';
+import {
+  SIGNUP_LOADING,
+  SENT_VERIFICATION,
+  LOGIN_LOADING,
+  AUTH_RENEW,
+  AUTH_USER,
+  SET_DOMAIN,
+  SHOW_PAGE_LOADING,
+  UNAUTH_USER
+} from '../actionTypes';
+
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+describe('auth actions', () => {
+  const email = 'test@mail.com';
+  const password = 'password';
+  const token =
+    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBcGlBdXRoIiwic3ViIjoidGVzdEBtYWlsLmNvbSIsImRvbWFpbiI6IiIsImlhdCI6MTUyOTEzNzczODcyNSwiZXhwIjoxNTI5MTM3NzM4NzI1fQ.tdI7r11bmSCUmbcJBBKIDt7Hkb7POLMRl8VNJv_8O_s';
+
+  describe('#signupUser()', () => {
+    it('should dispatch SENT_VERIFICATION when signing up user has been done', done => {
+      nock('http://localhost')
+        .post('/api/auth/signup')
+        .reply(200, {
+          email,
+          message: 'Verification email has been sent.'
+        });
+
+      const store = mockStore({});
+
+      const expectedActions = [
+        { type: SIGNUP_LOADING },
+        {
+          type: SENT_VERIFICATION,
+          payload: email
+        }
+      ];
+
+      store
+        .dispatch(signupUser(email, password))
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+
+  describe('#loginUser()', () => {
+    it('should dispatch AUTH_USER when logining user has been done', done => {
+      const pushStub = sinon.stub(Router, 'push');
+      pushStub.withArgs('/').returns('/');
+      const expectedRoute = '/';
+
+      nock('http://localhost')
+        .post('/api/auth/login')
+        .reply(200, {
+          token
+        });
+
+      const store = mockStore({});
+
+      const expectedActions = [
+        { type: LOGIN_LOADING },
+        { type: AUTH_RENEW },
+        {
+          type: AUTH_USER,
+          payload: email
+        },
+        {
+          type: SET_DOMAIN,
+          payload: ''
+        },
+        { type: SHOW_PAGE_LOADING }
+      ];
+
+      store
+        .dispatch(loginUser(email, password))
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+
+          pushStub.restore();
+          sinon.assert.calledWith(pushStub, expectedRoute);
+
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+
+  describe('#logoutUser()', () => {
+    it('should dispatch UNAUTH_USER when loging out user has been done', () => {
+      const pushStub = sinon.stub(Router, 'push');
+      pushStub.withArgs('/login').returns('/login');
+      const expectedRoute = '/login';
+
+      const store = mockStore({});
+
+      const expectedActions = [
+        { type: SHOW_PAGE_LOADING },
+        { type: UNAUTH_USER }
+      ];
+
+      store.dispatch(logoutUser());
+      expect(store.getActions()).to.deep.equal(expectedActions);
+
+      pushStub.restore();
+      sinon.assert.calledWith(pushStub, expectedRoute);
+    });
+  });
+
+  describe('#renewAuthUser()', () => {
+    it('should dispatch AUTH_RENEW when renewing auth user has been done', done => {
+      const cookieStub = sinon.stub(cookie, 'get');
+      cookieStub.withArgs('token').returns(token);
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .post('/api/auth/renew')
+        .reply(200, {
+          token
+        });
+
+      const store = mockStore({ auth: { renew: false } });
+
+      const expectedActions = [
+        { type: AUTH_RENEW },
+        {
+          type: AUTH_USER,
+          payload: email
+        },
+        {
+          type: SET_DOMAIN,
+          payload: ''
+        }
+      ];
+
+      store
+        .dispatch(renewAuthUser())
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          cookieStub.restore();
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+});

+ 165 - 0
client/actions/__test__/settings.js

@@ -0,0 +1,165 @@
+import nock from 'nock';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import cookie from 'js-cookie';
+import thunk from 'redux-thunk';
+import configureMockStore from 'redux-mock-store';
+
+import {
+  getUserSettings,
+  setCustomDomain,
+  deleteCustomDomain,
+  generateApiKey
+} from '../settings';
+import {
+  DELETE_DOMAIN,
+  DOMAIN_LOADING,
+  API_LOADING,
+  SET_DOMAIN,
+  SET_APIKEY
+} from '../actionTypes';
+
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+describe('settings actions', () => {
+  const token =
+    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBcGlBdXRoIiwic3ViIjoidGVzdEBtYWlsLmNvbSIsImRvbWFpbiI6IiIsImlhdCI6MTUyOTEzNzczODcyNSwiZXhwIjoxNTI5MTM3NzM4NzI1fQ.tdI7r11bmSCUmbcJBBKIDt7Hkb7POLMRl8VNJv_8O_s';
+
+  let cookieStub;
+
+  beforeEach(() => {
+    cookieStub = sinon.stub(cookie, 'get');
+    cookieStub.withArgs('token').returns(token);
+  });
+
+  afterEach(() => {
+    cookieStub.restore();
+  });
+
+  describe('#getUserSettings()', () => {
+    it('should dispatch SET_APIKEY and SET_DOMAIN when getting user settings have been done', done => {
+      const apikey = '123';
+      const customDomain = 'test.com';
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .get('/api/auth/usersettings')
+        .reply(200, { apikey, customDomain });
+
+      const store = mockStore({});
+
+      const expectedActions = [
+        {
+          type: SET_DOMAIN,
+          payload: customDomain
+        },
+        {
+          type: SET_APIKEY,
+          payload: apikey
+        }
+      ];
+
+      store
+        .dispatch(getUserSettings())
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+
+  describe('#setCustomDomain()', () => {
+    it('should dispatch SET_DOMAIN when setting custom domain has been done', done => {
+      const customDomain = 'test.com';
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .post('/api/url/customdomain')
+        .reply(200, { customDomain });
+
+      const store = mockStore({});
+
+      const expectedActions = [
+        { type: DOMAIN_LOADING },
+        {
+          type: SET_DOMAIN,
+          payload: customDomain
+        }
+      ];
+
+      store
+        .dispatch(setCustomDomain(customDomain))
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+
+  describe('#deleteCustomDomain()', () => {
+    it('should dispatch DELETE_DOMAIN when deleting custom domain has been done', done => {
+      const customDomain = 'test.com';
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .delete('/api/url/customdomain')
+        .reply(200, { customDomain });
+
+      const store = mockStore({});
+
+      const expectedActions = [{ type: DELETE_DOMAIN }];
+
+      store
+        .dispatch(deleteCustomDomain(customDomain))
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+
+  describe('#generateApiKey()', () => {
+    it('should dispatch SET_APIKEY when generating api key has been done', done => {
+      const apikey = '123';
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .post('/api/auth/generateapikey')
+        .reply(200, { apikey });
+
+      const store = mockStore({});
+
+      const expectedActions = [
+        { type: API_LOADING },
+        {
+          type: SET_APIKEY,
+          payload: apikey
+        }
+      ];
+
+      store
+        .dispatch(generateApiKey())
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+});

+ 159 - 0
client/actions/__test__/url.js

@@ -0,0 +1,159 @@
+import nock from 'nock';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import cookie from 'js-cookie';
+import thunk from 'redux-thunk';
+import configureMockStore from 'redux-mock-store';
+
+import { createShortUrl, getUrlsList, deleteShortUrl } from '../url';
+import { ADD_URL, LIST_URLS, DELETE_URL, TABLE_LOADING } from '../actionTypes';
+
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+describe('url actions', () => {
+  const token =
+    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBcGlBdXRoIiwic3ViIjoidGVzdEBtYWlsLmNvbSIsImRvbWFpbiI6IiIsImlhdCI6MTUyOTEzNzczODcyNSwiZXhwIjoxNTI5MTM3NzM4NzI1fQ.tdI7r11bmSCUmbcJBBKIDt7Hkb7POLMRl8VNJv_8O_s';
+
+  let cookieStub;
+
+  beforeEach(() => {
+    cookieStub = sinon.stub(cookie, 'get');
+    cookieStub.withArgs('token').returns(token);
+  });
+
+  afterEach(() => {
+    cookieStub.restore();
+  });
+
+  describe('#createShortUrl()', () => {
+    it('should dispatch ADD_URL when creating short url has been done', done => {
+      const url = 'test.com';
+      const mockedItems = {
+        createdAt: '2018-06-16T15:40:35.243Z',
+        id: '123',
+        target: url,
+        password: false,
+        reuse: false,
+        shortUrl: 'http://kutt.it/123'
+      };
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .post('/api/url/submit')
+        .reply(200, mockedItems);
+
+      const store = mockStore({});
+
+      const expectedActions = [
+        {
+          type: ADD_URL,
+          payload: mockedItems
+        }
+      ];
+
+      store
+        .dispatch(createShortUrl(url))
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+
+  describe('#getUrlsList()', () => {
+    it('should dispatch LIST_URLS when getting urls list has been done', done => {
+      const mockedQueryParams = {
+        isShortened: false,
+        count: 10,
+        countAll: 1,
+        page: 1,
+        search: ''
+      };
+
+      const mockedItems = {
+        list: [
+          {
+            createdAt: '2018-06-16T16:45:28.607Z',
+            id: 'UkEs33',
+            target: 'https://kutt.it/',
+            password: false,
+            count: 0,
+            shortUrl: 'http://test.com/UkEs33'
+          }
+        ],
+        countAll: 1
+      };
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .get('/api/url/geturls')
+        .query(mockedQueryParams)
+        .reply(200, mockedItems);
+
+      const store = mockStore({ url: { list: [], ...mockedQueryParams } });
+
+      const expectedActions = [
+        { type: TABLE_LOADING },
+        {
+          type: LIST_URLS,
+          payload: mockedItems
+        }
+      ];
+
+      store
+        .dispatch(getUrlsList())
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+
+  describe('#deleteShortUrl()', () => {
+    it('should dispatch DELETE_URL when deleting short url has been done', done => {
+      const id = '123';
+      const mockedItems = [
+        {
+          createdAt: '2018-06-16T15:40:35.243Z',
+          id: '123',
+          target: 'test.com',
+          password: false,
+          reuse: false,
+          shortUrl: 'http://kutt.it/123'
+        }
+      ];
+
+      nock('http://localhost', {
+        reqheaders: {
+          Authorization: token
+        }
+      })
+        .post('/api/url/deleteurl')
+        .reply(200, { message: 'Sort URL deleted successfully' });
+
+      const store = mockStore({ url: { list: mockedItems } });
+
+      const expectedActions = [
+        { type: TABLE_LOADING },
+        { type: DELETE_URL, payload: id }
+      ];
+
+      store
+        .dispatch(deleteShortUrl({ id }))
+        .then(() => {
+          expect(store.getActions()).to.deep.equal(expectedActions);
+          done();
+        })
+        .catch(error => done(error));
+    });
+  });
+});

+ 86 - 0
client/actions/auth.js

@@ -0,0 +1,86 @@
+import Router from 'next/router';
+import axios from 'axios';
+import cookie from 'js-cookie';
+import decodeJwt from 'jwt-decode';
+import {
+  SET_DOMAIN,
+  SHOW_PAGE_LOADING,
+  HIDE_PAGE_LOADING,
+  AUTH_USER,
+  UNAUTH_USER,
+  SENT_VERIFICATION,
+  AUTH_ERROR,
+  LOGIN_LOADING,
+  SIGNUP_LOADING,
+  AUTH_RENEW,
+} from './actionTypes';
+
+const setDomain = payload => ({ type: SET_DOMAIN, payload });
+
+export const showPageLoading = () => ({ type: SHOW_PAGE_LOADING });
+export const hidePageLoading = () => ({ type: HIDE_PAGE_LOADING });
+export const authUser = payload => ({ type: AUTH_USER, payload });
+export const unauthUser = () => ({ type: UNAUTH_USER });
+export const sentVerification = payload => ({
+  type: SENT_VERIFICATION,
+  payload,
+});
+export const showAuthError = payload => ({ type: AUTH_ERROR, payload });
+export const showLoginLoading = () => ({ type: LOGIN_LOADING });
+export const showSignupLoading = () => ({ type: SIGNUP_LOADING });
+export const authRenew = () => ({ type: AUTH_RENEW });
+
+export const signupUser = payload => async dispatch => {
+  dispatch(showSignupLoading());
+  try {
+    const { data: { email } } = await axios.post('/api/auth/signup', payload);
+    dispatch(sentVerification(email));
+  } catch ({ response }) {
+    dispatch(showAuthError(response.data.error));
+  }
+};
+
+export const loginUser = payload => async dispatch => {
+  dispatch(showLoginLoading());
+  try {
+    const { data: { token } } = await axios.post('/api/auth/login', payload);
+    cookie.set('token', token, { expires: 7 });
+    dispatch(authRenew());
+    dispatch(authUser(decodeJwt(token).sub));
+    dispatch(setDomain(decodeJwt(token).domain));
+    dispatch(showPageLoading());
+    Router.push('/');
+  } catch ({ response }) {
+    dispatch(showAuthError(response.data.error));
+  }
+};
+
+export const logoutUser = () => dispatch => {
+  dispatch(showPageLoading());
+  cookie.remove('token');
+  dispatch(unauthUser());
+  Router.push('/login');
+};
+
+export const renewAuthUser = () => async (dispatch, getState) => {
+  if (getState().auth.renew) {
+    return;
+  }
+
+  const options = {
+    method: 'POST',
+    headers: { Authorization: cookie.get('token') },
+    url: '/api/auth/renew',
+  };
+
+  try {
+    const { data: { token } } = await axios(options);
+    cookie.set('token', token, { expires: 7 });
+    dispatch(authRenew());
+    dispatch(authUser(decodeJwt(token).sub));
+    dispatch(setDomain(decodeJwt(token).domain));
+  } catch (error) {
+    cookie.remove('token');
+    dispatch(unauthUser());
+  }
+};

+ 60 - 141
client/actions/index.js

@@ -1,142 +1,61 @@
-import Router from 'next/router';
-import axios from 'axios';
-import cookie from 'js-cookie';
-import decodeJwt from 'jwt-decode';
-import * as types from './actionTypes';
-
-/* Homepage input actions */
-const addUrl = payload => ({ type: types.ADD_URL, payload });
-const listUrls = payload => ({ type: types.LIST_URLS, payload });
-const updateUrlList = payload => ({ type: types.UPDATE_URL_LIST, payload });
-const deleteUrl = payload => ({ type: types.DELETE_URL, payload });
-export const showShortenerLoading = () => ({ type: types.SHORTENER_LOADING });
-const showTableLoading = () => ({ type: types.TABLE_LOADING });
-export const setShortenerFormError = payload => ({ type: types.SHORTENER_ERROR, payload });
-
-export const createShortUrl = params => dispatch =>
-  axios
-    .post('/api/url/submit', params, { headers: { Authorization: cookie.get('token') } })
-    .then(({ data }) => dispatch(addUrl(data)))
-    .catch(({ response }) => dispatch(setShortenerFormError(response.data.error)));
-
-export const getUrlsList = params => (dispatch, getState) => {
-  if (params) dispatch(updateUrlList(params));
-  dispatch(showTableLoading());
-  const { url } = getState();
-  const query = Object.keys(url).reduce(
-    (string, item) => (typeof url[item] !== 'object' ? `${string + item}=${url[item]}&` : string),
-    '?'
-  );
-  return axios
-    .get(`/api/url/geturls${query}`, { headers: { Authorization: cookie.get('token') } })
-    .then(({ data }) => dispatch(listUrls(data)));
-};
-
-export const deleteShortUrl = params => dispatch => {
-  dispatch(showTableLoading());
-  return axios
-    .post('/api/url/deleteurl', params, { headers: { Authorization: cookie.get('token') } })
-    .then(() => dispatch(deleteUrl(params.id)))
-    .catch(({ response }) => dispatch(setShortenerFormError(response.data.error)));
-};
-/* Page loading actions */
-export const showPageLoading = () => ({ type: types.SHOW_PAGE_LOADING });
-export const hidePageLoading = () => ({ type: types.HIDE_PAGE_LOADING });
-
-/* Settings actions */
-export const setDomain = payload => ({ type: types.SET_DOMAIN, payload });
-export const setApiKey = payload => ({ type: types.SET_APIKEY, payload });
-const deleteDomain = () => ({ type: types.DELETE_DOMAIN });
-const setDomainError = payload => ({ type: types.DOMAIN_ERROR, payload });
-const showDomainLoading = () => ({ type: types.DOMAIN_LOADING });
-const showApiLoading = () => ({ type: types.API_LOADING });
-export const showDomainInput = () => ({ type: types.SHOW_DOMAIN_INPUT });
-
-export const getUserSettings = () => dispatch =>
-  axios
-    .get('/api/auth/usersettings', { headers: { Authorization: cookie.get('token') } })
-    .then(({ data }) => {
-      dispatch(setDomain(data.customDomain));
-      dispatch(setApiKey(data.apikey));
-    });
-
-export const setCustomDomain = params => dispatch => {
-  dispatch(showDomainLoading());
-  return axios
-    .post('/api/url/customdomain', params, { headers: { Authorization: cookie.get('token') } })
-    .then(({ data }) => dispatch(setDomain(data.customDomain)))
-    .catch(({ response }) => dispatch(setDomainError(response.data.error)));
-};
-
-export const deleteCustomDomain = () => dispatch =>
-  axios
-    .delete('/api/url/customdomain', { headers: { Authorization: cookie.get('token') } })
-    .then(() => dispatch(deleteDomain()))
-    .catch(({ response }) => dispatch(setDomainError(response.data.error)));
-
-export const generateApiKey = () => dispatch => {
-  dispatch(showApiLoading());
-  return axios
-    .post('/api/auth/generateapikey', null, { headers: { Authorization: cookie.get('token') } })
-    .then(({ data }) => dispatch(setApiKey(data.apikey)));
-};
-
-/* Login & signup actions */
-export const authUser = payload => ({ type: types.AUTH_USER, payload });
-export const unauthUser = () => ({ type: types.UNAUTH_USER });
-export const sentVerification = payload => ({ type: types.SENT_VERIFICATION, payload });
-export const showAuthError = payload => ({ type: types.AUTH_ERROR, payload });
-export const showLoginLoading = () => ({ type: types.LOGIN_LOADING });
-export const showSignupLoading = () => ({ type: types.SIGNUP_LOADING });
-export const authRenew = () => ({ type: types.AUTH_RENEW });
-
-export const signupUser = body => dispatch => {
-  dispatch(showSignupLoading());
-  return axios
-    .post('/api/auth/signup', body)
-    .then(res => {
-      const { email } = res.data;
-      dispatch(sentVerification(email));
-    })
-    .catch(err => dispatch(showAuthError(err.response.data.error)));
-};
-
-export const loginUser = body => dispatch => {
-  dispatch(showLoginLoading());
-  return axios
-    .post('/api/auth/login', body)
-    .then(res => {
-      const { token } = res.data;
-      cookie.set('token', token, { expires: 7 });
-      dispatch(authRenew());
-      dispatch(authUser(decodeJwt(token).sub));
-      dispatch(setDomain(decodeJwt(token).domain));
-      dispatch(showPageLoading());
-      Router.push('/');
-    })
-    .catch(err => dispatch(showAuthError(err.response.data.error)));
-};
-
-export const logoutUser = () => dispatch => {
-  dispatch(showPageLoading());
-  cookie.remove('token');
-  dispatch(unauthUser());
-  return Router.push('/login');
-};
-
-export const renewAuthUser = () => (dispatch, getState) => {
-  if (getState().auth.renew) return null;
-  return axios
-    .post('/api/auth/renew', null, { headers: { Authorization: cookie.get('token') } })
-    .then(res => {
-      const { token } = res.data;
-      cookie.set('token', token, { expires: 7 });
-      dispatch(authRenew());
-      dispatch(authUser(decodeJwt(token).sub));
-      dispatch(setDomain(decodeJwt(token).domain));
-    })
-    .catch(() => {
-      cookie.remove('token');
-      dispatch(unauthUser());
-    });
+import {
+  createShortUrl,
+  getUrlsList,
+  deleteShortUrl,
+  showShortenerLoading,
+  setShortenerFormError,
+} from './url';
+
+import {
+  setDomain,
+  setApiKey,
+  showDomainInput,
+  getUserSettings,
+  setCustomDomain,
+  deleteCustomDomain,
+  generateApiKey,
+} from './settings';
+
+import {
+  showPageLoading,
+  hidePageLoading,
+  authUser,
+  unauthUser,
+  sentVerification,
+  showAuthError,
+  showLoginLoading,
+  showSignupLoading,
+  authRenew,
+  signupUser,
+  loginUser,
+  logoutUser,
+  renewAuthUser,
+} from './auth';
+
+export {
+  createShortUrl,
+  getUrlsList,
+  deleteShortUrl,
+  showShortenerLoading,
+  setShortenerFormError,
+  setDomain,
+  setApiKey,
+  showDomainInput,
+  getUserSettings,
+  setCustomDomain,
+  deleteCustomDomain,
+  generateApiKey,
+  showPageLoading,
+  hidePageLoading,
+  authUser,
+  unauthUser,
+  sentVerification,
+  showAuthError,
+  showLoginLoading,
+  showSignupLoading,
+  authRenew,
+  signupUser,
+  loginUser,
+  logoutUser,
+  renewAuthUser,
 };

+ 67 - 0
client/actions/settings.js

@@ -0,0 +1,67 @@
+import axios from 'axios';
+import cookie from 'js-cookie';
+import {
+  DELETE_DOMAIN,
+  DOMAIN_ERROR,
+  DOMAIN_LOADING,
+  API_LOADING,
+  SET_DOMAIN,
+  SET_APIKEY,
+  SHOW_DOMAIN_INPUT,
+} from './actionTypes';
+
+const deleteDomain = () => ({ type: DELETE_DOMAIN });
+const setDomainError = payload => ({ type: DOMAIN_ERROR, payload });
+const showDomainLoading = () => ({ type: DOMAIN_LOADING });
+const showApiLoading = () => ({ type: API_LOADING });
+
+export const setDomain = payload => ({ type: SET_DOMAIN, payload });
+export const setApiKey = payload => ({ type: SET_APIKEY, payload });
+export const showDomainInput = () => ({ type: SHOW_DOMAIN_INPUT });
+
+export const getUserSettings = () => async dispatch => {
+  try {
+    const { data } = await axios.get('/api/auth/usersettings', {
+      headers: { Authorization: cookie.get('token') },
+    });
+    dispatch(setDomain(data.customDomain));
+    dispatch(setApiKey(data.apikey));
+  } catch (error) {
+    //
+  }
+};
+
+export const setCustomDomain = params => async dispatch => {
+  dispatch(showDomainLoading());
+  try {
+    const { data } = await axios.post('/api/url/customdomain', params, {
+      headers: { Authorization: cookie.get('token') },
+    });
+    dispatch(setDomain(data.customDomain));
+  } catch ({ response }) {
+    dispatch(setDomainError(response.data.error));
+  }
+};
+
+export const deleteCustomDomain = () => async dispatch => {
+  try {
+    await axios.delete('/api/url/customdomain', {
+      headers: { Authorization: cookie.get('token') },
+    });
+    dispatch(deleteDomain());
+  } catch ({ response }) {
+    dispatch(setDomainError(response.data.error));
+  }
+};
+
+export const generateApiKey = () => async dispatch => {
+  dispatch(showApiLoading());
+  try {
+    const { data } = await axios.post('/api/auth/generateapikey', null, {
+      headers: { Authorization: cookie.get('token') },
+    });
+    dispatch(setApiKey(data.apikey));
+  } catch (error) {
+    //
+  }
+};

+ 71 - 0
client/actions/url.js

@@ -0,0 +1,71 @@
+import axios from 'axios';
+import cookie from 'js-cookie';
+import {
+  ADD_URL,
+  LIST_URLS,
+  UPDATE_URL_LIST,
+  DELETE_URL,
+  SHORTENER_LOADING,
+  TABLE_LOADING,
+  SHORTENER_ERROR,
+} from './actionTypes';
+
+const addUrl = payload => ({ type: ADD_URL, payload });
+const listUrls = payload => ({ type: LIST_URLS, payload });
+const updateUrlList = payload => ({ type: UPDATE_URL_LIST, payload });
+const deleteUrl = payload => ({ type: DELETE_URL, payload });
+const showTableLoading = () => ({ type: TABLE_LOADING });
+
+export const setShortenerFormError = payload => ({
+  type: SHORTENER_ERROR,
+  payload,
+});
+
+export const showShortenerLoading = () => ({ type: SHORTENER_LOADING });
+
+export const createShortUrl = params => async dispatch => {
+  try {
+    const { data } = await axios.post('/api/url/submit', params, {
+      headers: { Authorization: cookie.get('token') },
+    });
+    dispatch(addUrl(data));
+  } catch ({ response }) {
+    dispatch(setShortenerFormError(response.data.error));
+  }
+};
+
+export const getUrlsList = params => async (dispatch, getState) => {
+  if (params) {
+    dispatch(updateUrlList(params));
+  }
+
+  dispatch(showTableLoading());
+
+  const { url } = getState();
+  const { list, ...queryParams } = url;
+  const query = Object.keys(queryParams).reduce(
+    (string, item) => `${string + item}=${queryParams[item]}&`,
+    '?'
+  );
+
+  try {
+    const { data } = await axios.get(`/api/url/geturls${query}`, {
+      headers: { Authorization: cookie.get('token') },
+    });
+    dispatch(listUrls(data));
+  } catch (error) {
+    //
+  }
+};
+
+export const deleteShortUrl = params => async dispatch => {
+  dispatch(showTableLoading());
+  try {
+    await axios.post('/api/url/deleteurl', params, {
+      headers: { Authorization: cookie.get('token') },
+    });
+    dispatch(deleteUrl(params.id));
+  } catch ({ response }) {
+    dispatch(setShortenerFormError(response.data.error));
+  }
+};

+ 82 - 0
client/reducers/__test__/auth.js

@@ -0,0 +1,82 @@
+import { expect } from 'chai';
+import deepFreeze from 'deep-freeze';
+
+import {
+  AUTH_USER,
+  AUTH_RENEW,
+  UNAUTH_USER,
+  SENT_VERIFICATION
+} from '../../actions/actionTypes';
+
+import reducer from '../auth';
+
+describe('auth reducer', () => {
+  const initialState = {
+    isAuthenticated: false,
+    sentVerification: false,
+    user: '',
+    renew: false
+  };
+
+  beforeEach(() => {
+    deepFreeze(initialState);
+  });
+
+  it('should return the initial state', () => {
+    expect(reducer(undefined, {})).to.deep.equal(initialState);
+  });
+
+  it('should handle AUTH_USER', () => {
+    const user = 'test@user.com';
+
+    const state = reducer(initialState, {
+      type: AUTH_USER,
+      payload: user
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.isAuthenticated).to.be.true;
+    expect(state.user).to.be.equal(user);
+    expect(state.sentVerification).to.be.false;
+  });
+
+  it('should handle AUTH_RENEW', () => {
+    const state = reducer(initialState, {
+      type: AUTH_RENEW
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.renew).to.be.true;
+  });
+
+  it('should handle UNAUTH_USER', () => {
+    const state = reducer(initialState, {
+      type: UNAUTH_USER
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+
+  it('should handle SENT_VERIFICATION', () => {
+    const user = 'test@user.com';
+
+    const state = reducer(initialState, {
+      type: SENT_VERIFICATION,
+      payload: user
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.sentVerification).to.be.true;
+    expect(state.user).to.be.equal(user);
+  });
+
+  it('should not handle other action types', () => {
+    const state = reducer(initialState, {
+      type: 'ANOTHER_ACTION'
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+});

+ 134 - 0
client/reducers/__test__/error.js

@@ -0,0 +1,134 @@
+import { expect } from 'chai';
+import deepFreeze from 'deep-freeze';
+
+import {
+  SHORTENER_ERROR,
+  DOMAIN_ERROR,
+  SET_DOMAIN,
+  SHOW_DOMAIN_INPUT,
+  ADD_URL,
+  UPDATE_URL,
+  AUTH_ERROR,
+  AUTH_USER,
+  HIDE_PAGE_LOADING
+} from '../../actions/actionTypes';
+
+import reducer from '../error';
+
+describe('error reducer', () => {
+  const initialState = {
+    auth: '',
+    domain: '',
+    shortener: '',
+    urlOptions: ''
+  };
+
+  beforeEach(() => {
+    deepFreeze(initialState);
+  });
+
+  it('should return the initial state', () => {
+    expect(reducer(undefined, {})).to.deep.equal(initialState);
+  });
+
+  it('should handle SHORTENER_ERROR', () => {
+    const error = 'SHORTENER_ERROR';
+
+    const state = reducer(initialState, {
+      type: SHORTENER_ERROR,
+      payload: error
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.shortener).to.be.equal(error);
+  });
+
+  it('should handle DOMAIN_ERROR', () => {
+    const error = 'DOMAIN_ERROR';
+
+    const state = reducer(initialState, {
+      type: DOMAIN_ERROR,
+      payload: error
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.domain).to.be.equal(error);
+  });
+
+  it('should handle SET_DOMAIN', () => {
+    const state = reducer(initialState, {
+      type: SET_DOMAIN
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.domain).to.be.empty;
+  });
+
+  it('should handle SHOW_DOMAIN_INPUT', () => {
+    const state = reducer(initialState, {
+      type: SHOW_DOMAIN_INPUT
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.domain).to.be.empty;
+  });
+
+  it('should handle ADD_URL', () => {
+    const state = reducer(initialState, {
+      type: ADD_URL
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.shortener).to.be.empty;
+  });
+
+  it('should handle UPDATE_URL', () => {
+    const state = reducer(initialState, {
+      type: UPDATE_URL
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.urlOptions).to.be.empty;
+  });
+
+  it('should handle AUTH_ERROR', () => {
+    const error = 'AUTH_ERROR';
+
+    const state = reducer(initialState, {
+      type: AUTH_ERROR,
+      payload: error
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.auth).to.be.equal(error);
+  });
+
+  it('should handle AUTH_USER', () => {
+    const state = reducer(initialState, {
+      type: AUTH_USER
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.auth).to.be.empty;
+  });
+
+  it('should handle HIDE_PAGE_LOADING', () => {
+    const state = reducer(initialState, {
+      type: HIDE_PAGE_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.auth).to.be.empty;
+    expect(state.shortener).to.be.empty;
+    expect(state.urlOptions).to.be.empty;
+  });
+
+  it('should not handle other action types', () => {
+    const state = reducer(initialState, {
+      type: 'ANOTHER_ACTION'
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+});

+ 211 - 0
client/reducers/__test__/loading.js

@@ -0,0 +1,211 @@
+import { expect } from 'chai';
+import deepFreeze from 'deep-freeze';
+
+import {
+  SHOW_PAGE_LOADING,
+  HIDE_PAGE_LOADING,
+  TABLE_LOADING,
+  LOGIN_LOADING,
+  SIGNUP_LOADING,
+  SHORTENER_LOADING,
+  ADD_URL,
+  SHORTENER_ERROR,
+  LIST_URLS,
+  DELETE_URL,
+  AUTH_ERROR,
+  AUTH_USER,
+  DOMAIN_LOADING,
+  SET_DOMAIN,
+  DOMAIN_ERROR,
+  API_LOADING,
+  SET_APIKEY
+} from '../../actions/actionTypes';
+
+import reducer from '../loading';
+
+describe('loading reducer', () => {
+  const initialState = {
+    api: false,
+    domain: false,
+    shortener: false,
+    login: false,
+    page: false,
+    table: false,
+    signup: false
+  };
+
+  beforeEach(() => {
+    deepFreeze(initialState);
+  });
+
+  it('should return the initial state', () => {
+    expect(reducer(undefined, {})).to.deep.equal(initialState);
+  });
+
+  it('should handle SHOW_PAGE_LOADING', () => {
+    const state = reducer(initialState, {
+      type: SHOW_PAGE_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.page).to.be.true;
+  });
+
+  it('should handle HIDE_PAGE_LOADING', () => {
+    const state = reducer(initialState, {
+      type: HIDE_PAGE_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.shortener).to.be.false;
+    expect(state.login).to.be.false;
+    expect(state.page).to.be.false;
+    expect(state.signup).to.be.false;
+  });
+
+  it('should handle TABLE_LOADING', () => {
+    const state = reducer(initialState, {
+      type: TABLE_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.table).to.be.true;
+  });
+
+  it('should handle LOGIN_LOADING', () => {
+    const state = reducer(initialState, {
+      type: LOGIN_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.login).to.be.true;
+  });
+
+  it('should handle SIGNUP_LOADING', () => {
+    const state = reducer(initialState, {
+      type: SIGNUP_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.signup).to.be.true;
+  });
+
+  it('should handle SHORTENER_LOADING', () => {
+    const state = reducer(initialState, {
+      type: SHORTENER_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.shortener).to.be.true;
+  });
+
+  it('should handle ADD_URL', () => {
+    const state = reducer(initialState, {
+      type: ADD_URL
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.shortener).to.be.false;
+  });
+
+  it('should handle SHORTENER_ERROR', () => {
+    const state = reducer(initialState, {
+      type: SHORTENER_ERROR
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.shortener).to.be.false;
+  });
+
+  it('should handle LIST_URLS', () => {
+    const state = reducer(initialState, {
+      type: LIST_URLS
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.table).to.be.false;
+  });
+
+  it('should handle DELETE_URL', () => {
+    const state = reducer(initialState, {
+      type: DELETE_URL
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.table).to.be.false;
+  });
+
+  it('should handle AUTH_ERROR', () => {
+    const state = reducer(initialState, {
+      type: AUTH_ERROR
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.login).to.be.false;
+    expect(state.signup).to.be.false;
+  });
+
+  it('should handle AUTH_USER', () => {
+    const state = reducer(initialState, {
+      type: AUTH_USER
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.login).to.be.false;
+    expect(state.signup).to.be.false;
+  });
+
+  it('should handle DOMAIN_LOADING', () => {
+    const state = reducer(initialState, {
+      type: DOMAIN_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.domain).to.be.true;
+  });
+
+  it('should handle SET_DOMAIN', () => {
+    const state = reducer(initialState, {
+      type: SET_DOMAIN
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.domain).to.be.false;
+  });
+
+  it('should handle DOMAIN_ERROR', () => {
+    const state = reducer(initialState, {
+      type: DOMAIN_ERROR
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.domain).to.be.false;
+  });
+
+  it('should handle API_LOADING', () => {
+    const state = reducer(initialState, {
+      type: API_LOADING
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.api).to.be.true;
+  });
+
+  it('should handle SET_APIKEY', () => {
+    const state = reducer(initialState, {
+      type: SET_APIKEY
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.api).to.be.false;
+  });
+
+  it('should not handle other action types', () => {
+    const state = reducer(initialState, {
+      type: 'ANOTHER_ACTION'
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+});

+ 90 - 0
client/reducers/__test__/settings.js

@@ -0,0 +1,90 @@
+import { expect } from 'chai';
+import deepFreeze from 'deep-freeze';
+
+import {
+  SET_DOMAIN,
+  SET_APIKEY,
+  DELETE_DOMAIN,
+  SHOW_DOMAIN_INPUT,
+  UNAUTH_USER
+} from '../../actions/actionTypes';
+
+import reducer from '../settings';
+
+describe('settings reducer', () => {
+  const initialState = {
+    apikey: '',
+    customDomain: '',
+    domainInput: true
+  };
+
+  beforeEach(() => {
+    deepFreeze(initialState);
+  });
+
+  it('should return the initial state', () => {
+    expect(reducer(undefined, {})).to.deep.equal(initialState);
+  });
+
+  it('should handle SET_DOMAIN', () => {
+    const domain = 'example.com';
+
+    const state = reducer(initialState, {
+      type: SET_DOMAIN,
+      payload: domain
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.customDomain).to.be.equal(domain);
+    expect(state.domainInput).to.be.false;
+  });
+
+  it('should handle SET_APIKEY', () => {
+    const apikey = '1234567';
+
+    const state = reducer(initialState, {
+      type: SET_APIKEY,
+      payload: apikey
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.apikey).to.be.equal(apikey);
+  });
+
+  it('should handle DELETE_DOMAIN', () => {
+    const state = reducer(initialState, {
+      type: DELETE_DOMAIN
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.customDomain).to.be.empty;
+    expect(state.domainInput).to.be.true;
+  });
+
+  it('should handle SHOW_DOMAIN_INPUT', () => {
+    const state = reducer(initialState, {
+      type: SHOW_DOMAIN_INPUT
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state.domainInput).to.be.true;
+  });
+
+  it('should handle UNAUTH_USER', () => {
+    const state = reducer(initialState, {
+      type: UNAUTH_USER
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+
+  it('should not handle other action types', () => {
+    const state = reducer(initialState, {
+      type: 'ANOTHER_ACTION'
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+});

+ 197 - 0
client/reducers/__test__/url.js

@@ -0,0 +1,197 @@
+import { expect } from 'chai';
+import deepFreeze from 'deep-freeze';
+
+import {
+  ADD_URL,
+  UPDATE_URL_LIST,
+  LIST_URLS,
+  DELETE_URL,
+  UNAUTH_USER
+} from '../../actions/actionTypes';
+
+import reducer from '../url';
+
+describe('url reducer', () => {
+  const initialState = {
+    list: [],
+    isShortened: false,
+    count: 10,
+    countAll: 0,
+    page: 1,
+    search: ''
+  };
+
+  beforeEach(() => {
+    deepFreeze(initialState);
+  });
+
+  it('should return the initial state', () => {
+    expect(reducer(undefined, {})).to.deep.equal(initialState);
+  });
+
+  it('should handle ADD_URL', () => {
+    const item = {
+      createdAt: '2018-06-12T19:23:00.272Z',
+      id: 'YufjdS',
+      target: 'https://kutt.it/',
+      password: false,
+      reuse: false,
+      shortUrl: 'https://kutt.it/YufjdS'
+    };
+
+    const state = reducer(initialState, {
+      type: ADD_URL,
+      payload: item
+    });
+
+    expect(state.list).to.be.an('array');
+    expect(state.list).to.have.lengthOf(1);
+    expect(state.list).to.include(item);
+    expect(state.isShortened).to.be.true;
+  });
+
+  it('should handle UPDATE_URL_LIST', () => {
+    const count = 10;
+    const page = 1;
+    const search = 'test url';
+
+    const allParamsState = reducer(initialState, {
+      type: UPDATE_URL_LIST,
+      payload: { count, page, search }
+    });
+
+    expect(allParamsState).not.to.be.undefined;
+    expect(allParamsState.count).to.be.equal(count);
+    expect(allParamsState.page).to.be.equal(page);
+    expect(allParamsState.search).to.be.equal(search);
+
+    const countState = reducer(initialState, {
+      type: UPDATE_URL_LIST,
+      payload: { count }
+    });
+
+    expect(countState).not.to.be.undefined;
+    expect(countState.count).to.be.equal(count);
+
+    const pageState = reducer(initialState, {
+      type: UPDATE_URL_LIST,
+      payload: { page }
+    });
+
+    expect(pageState).not.to.be.undefined;
+    expect(pageState.page).to.be.equal(page);
+
+    const searchState = reducer(initialState, {
+      type: UPDATE_URL_LIST,
+      payload: { search }
+    });
+
+    expect(searchState).not.to.be.undefined;
+    expect(searchState.search).to.be.equal(search);
+
+    const state = reducer(initialState, {
+      type: UPDATE_URL_LIST
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+
+  it('should handle LIST_URLS', () => {
+    const list = [
+      {
+        createdAt: '2018-06-12T19:23:00.272Z',
+        id: 'YufjdS',
+        target: 'https://kutt.it/',
+        password: false,
+        reuse: false,
+        shortUrl: 'https://kutt.it/YufjdS'
+      },
+      {
+        createdAt: '2018-06-12T19:51:56.435Z',
+        id: '1gCdbC',
+        target: 'https://kutt.it/',
+        password: false,
+        reuse: false,
+        shortUrl: 'https://kutt.it/1gCdbC'
+      }
+    ];
+
+    const countAll = list.length;
+
+    const state = reducer(initialState, {
+      type: LIST_URLS,
+      payload: { list, countAll }
+    });
+
+    expect(state.list).to.be.an('array');
+    expect(state.list).to.have.lengthOf(2);
+    expect(state.list).to.not.include(list);
+    expect(state.countAll).to.be.equal(countAll);
+    expect(state.isShortened).to.be.false;
+  });
+
+  it('should handle DELETE_URL', () => {
+    const itemsState = {
+      list: [
+        {
+          createdAt: '2018-06-12T19:23:00.272Z',
+          id: 'YufjdS',
+          target: 'https://kutt.it/',
+          password: false,
+          reuse: false,
+          shortUrl: 'https://kutt.it/YufjdS'
+        },
+        {
+          createdAt: '2018-06-12T19:51:56.435Z',
+          id: '1gCdbC',
+          target: 'https://kutt.it/',
+          password: false,
+          reuse: false,
+          shortUrl: 'https://kutt.it/1gCdbC'
+        }
+      ],
+      isShortened: true,
+      count: 10,
+      countAll: 2,
+      page: 1,
+      search: ''
+    };
+
+    deepFreeze(itemsState);
+
+    const state = reducer(itemsState, {
+      type: DELETE_URL,
+      payload: 'YufjdS'
+    });
+
+    expect(state.list).to.be.an('array');
+    expect(state.list).to.have.lengthOf(1);
+    expect(state.list).to.not.include({
+      createdAt: '2018-06-12T19:23:00.272Z',
+      id: 'YufjdS',
+      target: 'https://kutt.it/',
+      password: false,
+      reuse: false,
+      shortUrl: 'https://kutt.it/YufjdS'
+    });
+  });
+
+  it('should handle UNAUTH_USER', () => {
+    const state = reducer(initialState, {
+      type: UNAUTH_USER
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+
+  it('should not handle other action types', () => {
+    const state = reducer(initialState, {
+      type: 'ANOTHER_ACTION'
+    });
+
+    expect(state).not.to.be.undefined;
+    expect(state).to.deep.equal(initialState);
+  });
+});

+ 30 - 0
client/reducers/auth.js

@@ -0,0 +1,30 @@
+import { AUTH_USER, AUTH_RENEW, UNAUTH_USER, SENT_VERIFICATION } from '../actions/actionTypes';
+
+const initialState = {
+  isAuthenticated: false,
+  sentVerification: false,
+  user: '',
+  renew: false,
+};
+
+const auth = (state = initialState, action) => {
+  switch (action.type) {
+    case AUTH_USER:
+      return {
+        ...state,
+        isAuthenticated: true,
+        user: action.payload,
+        sentVerification: false,
+      };
+    case AUTH_RENEW:
+      return { ...state, renew: true };
+    case UNAUTH_USER:
+      return initialState;
+    case SENT_VERIFICATION:
+      return { ...state, sentVerification: true, user: action.payload };
+    default:
+      return state;
+  }
+};
+
+export default auth;

+ 49 - 0
client/reducers/error.js

@@ -0,0 +1,49 @@
+import {
+  SHORTENER_ERROR,
+  DOMAIN_ERROR,
+  SET_DOMAIN,
+  SHOW_DOMAIN_INPUT,
+  ADD_URL,
+  UPDATE_URL,
+  AUTH_ERROR,
+  AUTH_USER,
+  HIDE_PAGE_LOADING,
+} from '../actions/actionTypes';
+
+const initialState = {
+  auth: '',
+  domain: '',
+  shortener: '',
+  urlOptions: '',
+};
+
+const error = (state = initialState, action) => {
+  switch (action.type) {
+    case SHORTENER_ERROR:
+      return { ...state, shortener: action.payload };
+    case DOMAIN_ERROR:
+      return { ...state, domain: action.payload };
+    case SET_DOMAIN:
+    case SHOW_DOMAIN_INPUT:
+      return { ...state, domain: '' };
+    case ADD_URL:
+      return { ...state, shortener: '' };
+    case UPDATE_URL:
+      return { ...state, urlOptions: '' };
+    case AUTH_ERROR:
+      return { ...state, auth: action.payload };
+    case AUTH_USER:
+      return { ...state, auth: '' };
+    case HIDE_PAGE_LOADING:
+      return {
+        ...state,
+        auth: '',
+        shortener: '',
+        urlOptions: '',
+      };
+    default:
+      return state;
+  }
+};
+
+export default error;

+ 6 - 180
client/reducers/index.js

@@ -1,191 +1,17 @@
 import { combineReducers } from 'redux';
-import * as types from '../actions/actionTypes';
 
-const initialState = {
-  list: [],
-  isShortened: false,
-  count: 10,
-  countAll: 0,
-  page: 1,
-  search: '',
-};
-
-const url = (state = initialState, action) => {
-  const { count, page, search } = action.payload || {};
-  const isSearch = typeof search !== 'undefined';
-  switch (action.type) {
-    case types.ADD_URL:
-      return { ...state, isShortened: true, list: [action.payload, ...state.list] };
-    case types.UPDATE_URL_LIST:
-      return Object.assign({}, state, count && { count }, page && { page }, isSearch && { search });
-    case types.LIST_URLS:
-      return {
-        ...state,
-        list: action.payload.list,
-        countAll: action.payload.countAll,
-        isShortened: false,
-      };
-    case types.DELETE_URL:
-      return { ...state, list: state.list.filter(item => item.id !== action.payload) };
-    case types.UNAUTH_USER:
-      return initialState;
-    default:
-      return state;
-  }
-};
-
-/* All errors */
-const errorInitialState = {
-  auth: '',
-  domain: '',
-  shortener: '',
-};
-
-const error = (state = errorInitialState, action) => {
-  switch (action.type) {
-    case types.SHORTENER_ERROR:
-      return { ...state, shortener: action.payload };
-    case types.DOMAIN_ERROR:
-      return { ...state, domain: action.payload };
-    case types.SET_DOMAIN:
-      return { ...state, domain: '' };
-    case types.SHOW_DOMAIN_INPUT:
-      return { ...state, domain: '' };
-    case types.ADD_URL:
-      return { ...state, shortener: '' };
-    case types.UPDATE_URL:
-      return { ...state, urlOptions: '' };
-    case types.AUTH_ERROR:
-      return { ...state, auth: action.payload };
-    case types.AUTH_USER:
-      return { ...state, auth: '' };
-    case types.HIDE_PAGE_LOADING:
-      return {
-        ...state,
-        auth: '',
-        shortener: '',
-        urlOptions: '',
-      };
-    default:
-      return state;
-  }
-};
-
-/* All loadings */
-const loadingInitialState = {
-  api: false,
-  domain: false,
-  shortener: false,
-  login: false,
-  page: false,
-  table: false,
-  signup: false,
-};
-
-const loading = (state = loadingInitialState, action) => {
-  switch (action.type) {
-    case types.SHOW_PAGE_LOADING:
-      return { ...state, page: true };
-    case types.HIDE_PAGE_LOADING:
-      return {
-        shortener: false,
-        login: false,
-        page: false,
-        signup: false,
-      };
-    case types.TABLE_LOADING:
-      return { ...state, table: true };
-    case types.LOGIN_LOADING:
-      return { ...state, login: true };
-    case types.SIGNUP_LOADING:
-      return { ...state, signup: true };
-    case types.SHORTENER_LOADING:
-      return { ...state, shortener: true };
-    case types.ADD_URL:
-      return { ...state, shortener: false };
-    case types.SHORTENER_ERROR:
-      return { ...state, shortener: false };
-    case types.LIST_URLS:
-      return { ...state, table: false };
-    case types.DELETE_URL:
-      return { ...state, table: false };
-    case types.AUTH_ERROR:
-      return { ...state, login: false, signup: false };
-    case types.AUTH_USER:
-      return { ...state, login: false, signup: false };
-    case types.DOMAIN_LOADING:
-      return { ...state, domain: true };
-    case types.SET_DOMAIN:
-      return { ...state, domain: false };
-    case types.DOMAIN_ERROR:
-      return { ...state, domain: false };
-    case types.API_LOADING:
-      return { ...state, api: true };
-    case types.SET_APIKEY:
-      return { ...state, api: false };
-    default:
-      return state;
-  }
-};
-
-/* User authentication */
-const authInitialState = {
-  isAuthenticated: false,
-  sentVerification: false,
-  user: '',
-  renew: false,
-};
-
-const auth = (state = authInitialState, action) => {
-  switch (action.type) {
-    case types.AUTH_USER:
-      return {
-        ...state,
-        isAuthenticated: true,
-        user: action.payload,
-        sentVerification: false,
-      };
-    case types.AUTH_RENEW:
-      return { ...state, renew: true };
-    case types.UNAUTH_USER:
-      return authInitialState;
-    case types.SENT_VERIFICATION:
-      return { ...state, sentVerification: true, user: action.payload };
-    default:
-      return state;
-  }
-};
-
-/* Settings */
-const settingsInitialState = {
-  apikey: '',
-  customDomain: '',
-  domainInput: true,
-};
-
-const settings = (state = settingsInitialState, action) => {
-  switch (action.type) {
-    case types.SET_DOMAIN:
-      return { ...state, customDomain: action.payload, domainInput: false };
-    case types.SET_APIKEY:
-      return { ...state, apikey: action.payload };
-    case types.DELETE_DOMAIN:
-      return { ...state, customDomain: '', domainInput: true };
-    case types.SHOW_DOMAIN_INPUT:
-      return { ...state, domainInput: true };
-    case types.UNAUTH_USER:
-      return settingsInitialState;
-    default:
-      return state;
-  }
-};
+import url from './url';
+import auth from './auth';
+import error from './error';
+import loading from './loading';
+import settings from './settings';
 
 const rootReducer = combineReducers({
+  url,
   auth,
   error,
   loading,
   settings,
-  url,
 });
 
 export default rootReducer;

+ 73 - 0
client/reducers/loading.js

@@ -0,0 +1,73 @@
+import {
+  SHOW_PAGE_LOADING,
+  HIDE_PAGE_LOADING,
+  TABLE_LOADING,
+  LOGIN_LOADING,
+  SIGNUP_LOADING,
+  SHORTENER_LOADING,
+  ADD_URL,
+  SHORTENER_ERROR,
+  LIST_URLS,
+  DELETE_URL,
+  AUTH_ERROR,
+  AUTH_USER,
+  DOMAIN_LOADING,
+  SET_DOMAIN,
+  DOMAIN_ERROR,
+  API_LOADING,
+  SET_APIKEY,
+} from '../actions/actionTypes';
+
+const initialState = {
+  api: false,
+  domain: false,
+  shortener: false,
+  login: false,
+  page: false,
+  table: false,
+  signup: false,
+};
+
+const loading = (state = initialState, action) => {
+  switch (action.type) {
+    case SHOW_PAGE_LOADING:
+      return { ...state, page: true };
+    case HIDE_PAGE_LOADING:
+      return {
+        shortener: false,
+        login: false,
+        page: false,
+        signup: false,
+      };
+    case TABLE_LOADING:
+      return { ...state, table: true };
+    case LOGIN_LOADING:
+      return { ...state, login: true };
+    case SIGNUP_LOADING:
+      return { ...state, signup: true };
+    case SHORTENER_LOADING:
+      return { ...state, shortener: true };
+    case ADD_URL:
+    case SHORTENER_ERROR:
+      return { ...state, shortener: false };
+    case LIST_URLS:
+    case DELETE_URL:
+      return { ...state, table: false };
+    case AUTH_ERROR:
+    case AUTH_USER:
+      return { ...state, login: false, signup: false };
+    case DOMAIN_LOADING:
+      return { ...state, domain: true };
+    case SET_DOMAIN:
+    case DOMAIN_ERROR:
+      return { ...state, domain: false };
+    case API_LOADING:
+      return { ...state, api: true };
+    case SET_APIKEY:
+      return { ...state, api: false };
+    default:
+      return state;
+  }
+};
+
+export default loading;

+ 32 - 0
client/reducers/settings.js

@@ -0,0 +1,32 @@
+import {
+  SET_DOMAIN,
+  SET_APIKEY,
+  DELETE_DOMAIN,
+  SHOW_DOMAIN_INPUT,
+  UNAUTH_USER,
+} from '../actions/actionTypes';
+
+const initialState = {
+  apikey: '',
+  customDomain: '',
+  domainInput: true,
+};
+
+const settings = (state = initialState, action) => {
+  switch (action.type) {
+    case SET_DOMAIN:
+      return { ...state, customDomain: action.payload, domainInput: false };
+    case SET_APIKEY:
+      return { ...state, apikey: action.payload };
+    case DELETE_DOMAIN:
+      return { ...state, customDomain: '', domainInput: true };
+    case SHOW_DOMAIN_INPUT:
+      return { ...state, domainInput: true };
+    case UNAUTH_USER:
+      return initialState;
+    default:
+      return state;
+  }
+};
+
+export default settings;

+ 49 - 0
client/reducers/url.js

@@ -0,0 +1,49 @@
+import {
+  ADD_URL,
+  UPDATE_URL_LIST,
+  LIST_URLS,
+  DELETE_URL,
+  UNAUTH_USER,
+} from '../actions/actionTypes';
+
+const initialState = {
+  list: [],
+  isShortened: false,
+  count: 10,
+  countAll: 0,
+  page: 1,
+  search: '',
+};
+
+const url = (state = initialState, action) => {
+  const { count, page, search } = action.payload || {};
+  const isSearch = typeof search !== 'undefined';
+  switch (action.type) {
+    case ADD_URL:
+      return {
+        ...state,
+        isShortened: true,
+        list: [action.payload, ...state.list],
+      };
+    case UPDATE_URL_LIST:
+      return Object.assign({}, state, count && { count }, page && { page }, isSearch && { search });
+    case LIST_URLS:
+      return {
+        ...state,
+        list: action.payload.list,
+        countAll: action.payload.countAll,
+        isShortened: false,
+      };
+    case DELETE_URL:
+      return {
+        ...state,
+        list: state.list.filter(item => item.id !== action.payload),
+      };
+    case UNAUTH_USER:
+      return initialState;
+    default:
+      return state;
+  }
+};
+
+export default url;

+ 11 - 2
package.json

@@ -4,6 +4,7 @@
   "description": "Modern URL shortener.",
   "main": "./server/server.js",
   "scripts": {
+    "test": "mocha --require babel-core/register './client/**/__test__/*.js'",
     "dev": "node ./server/server.js",
     "build": "next build ./client",
     "start": "NODE_ENV=production node ./server/server.js",
@@ -29,7 +30,7 @@
   },
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "dependencies": {
-    "axios": "^0.17.1",
+    "axios": "^0.18.0",
     "bcryptjs": "^2.4.3",
     "body-parser": "^1.18.2",
     "cookie-parser": "^1.4.3",
@@ -71,9 +72,13 @@
     "useragent": "^2.2.1"
   },
   "devDependencies": {
+    "babel": "^6.23.0",
     "babel-cli": "^6.26.0",
+    "babel-core": "^6.26.3",
     "babel-eslint": "^8.0.2",
     "babel-plugin-styled-components": "^1.3.0",
+    "chai": "^4.1.2",
+    "deep-freeze": "^0.0.1",
     "eslint": "^4.12.0",
     "eslint-config-airbnb": "^16.1.0",
     "eslint-config-prettier": "^2.9.0",
@@ -82,6 +87,10 @@
     "eslint-plugin-prettier": "^2.6.0",
     "eslint-plugin-react": "^7.5.1",
     "husky": "^0.15.0-rc.6",
-    "prettier": "^1.10.2"
+    "mocha": "^5.2.0",
+    "nock": "^9.3.3",
+    "prettier": "^1.10.2",
+    "redux-mock-store": "^1.5.3",
+    "sinon": "^6.0.0"
   }
 }

+ 1 - 1
server/controllers/validateBodyController.js

@@ -72,7 +72,7 @@ exports.validateUrl = async ({ body, user }, res, next) => {
   // Custom URL validations
   if (user && body.customurl) {
     // Validate custom URL
-    if (!/^[a-zA-Z1-9-_]+$/g.test(body.customurl.trim())) {
+    if (!/^[a-zA-Z0-9-_]+$/g.test(body.customurl.trim())) {
       return res.status(400).json({ error: 'Custom URL is not valid.' });
     }
 

+ 233 - 28
yarn.lock

@@ -77,6 +77,12 @@
     lodash "^4.2.0"
     to-fast-properties "^2.0.0"
 
+"@sinonjs/formatio@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2"
+  dependencies:
+    samsam "1.3.0"
+
 "@types/body-parser@*":
   version "1.16.8"
   resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.16.8.tgz#687ec34140624a3bec2b1a8ea9268478ae8f3be3"
@@ -374,6 +380,10 @@ assert@^1.1.1:
   dependencies:
     util "0.10.3"
 
+assertion-error@^1.0.1:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
+
 assign-symbols@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
@@ -412,11 +422,11 @@ aws4@^1.2.1, aws4@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
 
-axios@^0.17.1:
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.17.1.tgz#2d8e3e5d0bdbd7327f91bc814f5c57660f81824d"
+axios@^0.18.0:
+  version "0.18.0"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102"
   dependencies:
-    follow-redirects "^1.2.5"
+    follow-redirects "^1.3.0"
     is-buffer "^1.1.5"
 
 axobject-query@^0.1.0:
@@ -478,6 +488,30 @@ babel-core@6.26.0, babel-core@^6.26.0:
     slash "^1.0.0"
     source-map "^0.5.6"
 
+babel-core@^6.26.3:
+  version "6.26.3"
+  resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207"
+  dependencies:
+    babel-code-frame "^6.26.0"
+    babel-generator "^6.26.0"
+    babel-helpers "^6.24.1"
+    babel-messages "^6.23.0"
+    babel-register "^6.26.0"
+    babel-runtime "^6.26.0"
+    babel-template "^6.26.0"
+    babel-traverse "^6.26.0"
+    babel-types "^6.26.0"
+    babylon "^6.18.0"
+    convert-source-map "^1.5.1"
+    debug "^2.6.9"
+    json5 "^0.5.1"
+    lodash "^4.17.4"
+    minimatch "^3.0.4"
+    path-is-absolute "^1.0.1"
+    private "^0.1.8"
+    slash "^1.0.0"
+    source-map "^0.5.7"
+
 babel-eslint@^8.0.2:
   version "8.2.2"
   resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-8.2.2.tgz#1102273354c6f0b29b4ea28a65f97d122296b68b"
@@ -777,8 +811,8 @@ babel-plugin-transform-es2015-modules-amd@^6.22.0, babel-plugin-transform-es2015
     babel-template "^6.24.1"
 
 babel-plugin-transform-es2015-modules-commonjs@^6.23.0, babel-plugin-transform-es2015-modules-commonjs@^6.24.1:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz#0d8394029b7dc6abe1a97ef181e00758dd2e5d8a"
+  version "6.26.2"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz#58a793863a9e7ca870bdc5a881117ffac27db6f3"
   dependencies:
     babel-plugin-transform-strict-mode "^6.24.1"
     babel-runtime "^6.26.0"
@@ -1047,6 +1081,10 @@ babel-types@6.26.0, babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.
     lodash "^4.17.4"
     to-fast-properties "^1.0.3"
 
+babel@^6.23.0:
+  version "6.23.0"
+  resolved "https://registry.yarnpkg.com/babel/-/babel-6.23.0.tgz#d0d1e7d803e974765beea3232d4e153c0efb90f4"
+
 babylon@7.0.0-beta.40, babylon@^7.0.0-beta.40:
   version "7.0.0-beta.40"
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.40.tgz#91fc8cd56d5eb98b28e6fde41045f2957779940a"
@@ -1209,6 +1247,10 @@ brorand@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
 
+browser-stdout@1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
+
 browserify-aes@^1.0.0, browserify-aes@^1.0.4:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.1.1.tgz#38b7ab55edb806ff2dcda1a7f1620773a477c49f"
@@ -1362,8 +1404,8 @@ camelize@1.0.0:
   resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
 
 caniuse-lite@^1.0.30000792:
-  version "1.0.30000814"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000814.tgz#73eb6925ac2e54d495218f1ea0007da3940e488b"
+  version "1.0.30000856"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000856.tgz#ecc16978135a6f219b138991eb62009d25ee8daa"
 
 capture-stack-trace@^1.0.0:
   version "1.0.0"
@@ -1384,6 +1426,17 @@ center-align@^0.1.1:
     align-text "^0.1.3"
     lazy-cache "^1.0.3"
 
+chai@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c"
+  dependencies:
+    assertion-error "^1.0.1"
+    check-error "^1.0.1"
+    deep-eql "^3.0.0"
+    get-func-name "^2.0.0"
+    pathval "^1.0.0"
+    type-detect "^4.0.0"
+
 chain-function@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
@@ -1394,7 +1447,7 @@ chainsaw@~0.1.0:
   dependencies:
     traverse ">=0.3.0 <0.4"
 
-chalk@2.3.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0:
+chalk@2.3.2, chalk@^2.0.0, chalk@^2.1.0:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.2.tgz#250dc96b07491bfd601e648d66ddf5f60c7a5c65"
   dependencies:
@@ -1412,6 +1465,14 @@ chalk@^1.1.1, chalk@^1.1.3:
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
+chalk@^2.0.1:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
 chardet@^0.4.0:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
@@ -1420,6 +1481,10 @@ charenc@~0.0.1:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
 
+check-error@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
+
 chokidar@^1.6.1:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
@@ -1550,6 +1615,10 @@ combined-stream@1.0.6, combined-stream@^1.0.5, combined-stream@~1.0.5:
   dependencies:
     delayed-stream "~1.0.0"
 
+commander@2.15.1:
+  version "2.15.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f"
+
 commander@^2.11.0:
   version "2.15.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.0.tgz#ad2a23a1c3b036e392469b8012cec6b33b4c1322"
@@ -1579,8 +1648,8 @@ concat-stream@^1.5.0, concat-stream@^1.6.0:
     typedarray "^0.0.6"
 
 configstore@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.1.tgz#094ee662ab83fad9917678de114faaea8fcdca90"
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
   dependencies:
     dot-prop "^4.1.0"
     graceful-fs "^4.1.2"
@@ -1619,7 +1688,7 @@ content-type@~1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
 
-convert-source-map@1.5.1, convert-source-map@^1.5.0:
+convert-source-map@1.5.1, convert-source-map@^1.5.0, convert-source-map@^1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5"
 
@@ -1865,7 +1934,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
   dependencies:
     ms "2.0.0"
 
-debug@^3.0.1, debug@^3.1.0:
+debug@3.1.0, debug@^3.0.1, debug@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   dependencies:
@@ -1879,10 +1948,24 @@ decode-uri-component@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
 
+deep-eql@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
+  dependencies:
+    type-detect "^4.0.0"
+
+deep-equal@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+
 deep-extend@~0.4.0:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
 
+deep-freeze@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/deep-freeze/-/deep-freeze-0.0.1.tgz#3a0b0005de18672819dfd38cd31f91179c893e84"
+
 deep-is@~0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
@@ -1973,6 +2056,10 @@ detect-libc@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
 
+diff@3.5.0, diff@^3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
+
 diffie-hellman@^5.0.0:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
@@ -2051,8 +2138,8 @@ ee-first@1.1.1:
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
 
 electron-to-chromium@^1.3.30:
-  version "1.3.37"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.37.tgz#4a92734e0044c8cf0b1553be57eae21a4c6e5fab"
+  version "1.3.48"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.48.tgz#d3b0d8593814044e092ece2108fc3ac9aea4b900"
 
 elliptic@^6.0.0:
   version "6.4.0"
@@ -2199,7 +2286,7 @@ escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
 
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
 
@@ -2665,9 +2752,9 @@ flush-write-stream@^1.0.0:
     inherits "^2.0.1"
     readable-stream "^2.0.4"
 
-follow-redirects@^1.2.5:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.4.1.tgz#d8120f4518190f55aac65bb6fc7b85fcd666d6aa"
+follow-redirects@^1.3.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.0.tgz#234f49cf770b7f35b40e790f636ceba0c3a0ab77"
   dependencies:
     debug "^3.1.0"
 
@@ -2829,6 +2916,10 @@ get-caller-file@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
 
+get-func-name@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
+
 get-stdin@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398"
@@ -2950,6 +3041,10 @@ graceful-fs@~3.0.2:
   dependencies:
     natives "^1.1.0"
 
+growl@1.10.5:
+  version "1.10.5"
+  resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e"
+
 har-schema@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
@@ -3069,6 +3164,10 @@ hawk@~6.0.2:
     hoek "4.x.x"
     sntp "2.x.x"
 
+he@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
+
 helmet-csp@2.7.0:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/helmet-csp/-/helmet-csp-2.7.0.tgz#7934094617d1feb7bb2dc43bb7d9e8830f774716"
@@ -3649,7 +3748,7 @@ json-stable-stringify@^1.0.1:
   dependencies:
     jsonify "~0.0.0"
 
-json-stringify-safe@~5.0.1:
+json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
 
@@ -3705,6 +3804,10 @@ junk@^1.0.1:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/junk/-/junk-1.0.3.tgz#87be63488649cbdca6f53ab39bec9ccd2347f592"
 
+just-extend@^1.1.27:
+  version "1.1.27"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905"
+
 jwa@^1.1.4:
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5"
@@ -3814,6 +3917,10 @@ lodash-es@^4.17.5, lodash-es@^4.2.1:
   version "4.17.7"
   resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.7.tgz#db240a3252c3dd8360201ac9feef91ac977ea856"
 
+lodash.get@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+
 lodash.includes@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
@@ -3850,6 +3957,10 @@ lodash@^4.14.0, lodash@^4.16.0, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, l
   version "4.17.5"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
 
+lolex@^2.3.2, lolex@^2.4.2:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.0.tgz#9c087a69ec440e39d3f796767cf1b2cdc43d5ea5"
+
 longest@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
@@ -3861,8 +3972,8 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1:
     js-tokens "^3.0.0"
 
 lowercase-keys@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306"
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
 
 lru-cache@4.1.x, lru-cache@^4.0.1, lru-cache@^4.1.1:
   version "4.1.2"
@@ -4026,7 +4137,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
 
-minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
+minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   dependencies:
@@ -4069,12 +4180,28 @@ mkdirp-then@1.2.0:
     any-promise "^1.1.0"
     mkdirp "^0.5.0"
 
-mkdirp@0.5, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0:
+mkdirp@0.5, mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0:
   version "0.5.1"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
   dependencies:
     minimist "0.0.8"
 
+mocha@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.2.0.tgz#6d8ae508f59167f940f2b5b3c4a612ae50c90ae6"
+  dependencies:
+    browser-stdout "1.3.1"
+    commander "2.15.1"
+    debug "3.1.0"
+    diff "3.5.0"
+    escape-string-regexp "1.0.5"
+    glob "7.1.2"
+    growl "1.10.5"
+    he "1.1.1"
+    minimatch "3.0.4"
+    mkdirp "0.5.1"
+    supports-color "5.4.0"
+
 moment@2.x.x, moment@^2.11.2:
   version "2.21.0"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a"
@@ -4233,10 +4360,34 @@ next@^5.1.0:
     webpack-sources "1.1.0"
     write-file-webpack-plugin "4.2.0"
 
+nise@^1.3.3:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-1.4.2.tgz#a9a3800e3994994af9e452333d549d60f72b8e8c"
+  dependencies:
+    "@sinonjs/formatio" "^2.0.0"
+    just-extend "^1.1.27"
+    lolex "^2.3.2"
+    path-to-regexp "^1.7.0"
+    text-encoding "^0.6.4"
+
 nocache@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.0.0.tgz#202b48021a0c4cbde2df80de15a17443c8b43980"
 
+nock@^9.3.3:
+  version "9.3.3"
+  resolved "https://registry.yarnpkg.com/nock/-/nock-9.3.3.tgz#d9f4cd3c011afeadaf5ccf1b243f6e353781cda0"
+  dependencies:
+    chai "^4.1.2"
+    debug "^3.1.0"
+    deep-equal "^1.0.0"
+    json-stringify-safe "^5.0.1"
+    lodash "^4.17.5"
+    mkdirp "^0.5.0"
+    propagate "^1.0.0"
+    qs "^6.5.1"
+    semver "^5.5.0"
+
 node-fetch@^1.0.1:
   version "1.7.3"
   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
@@ -4619,6 +4770,12 @@ path-to-regexp@2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.1.0.tgz#7e30f9f5b134bd6a28ffc2e3ef1e47075ac5259b"
 
+path-to-regexp@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d"
+  dependencies:
+    isarray "0.0.1"
+
 path-type@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
@@ -4631,6 +4788,10 @@ path-type@^3.0.0:
   dependencies:
     pify "^3.0.0"
 
+pathval@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
+
 pause@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d"
@@ -4719,7 +4880,7 @@ prettier@^1.10.2:
   version "1.11.1"
   resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.11.1.tgz#61e43fc4cd44e68f2b0dfc2c38cd4bb0fccdcc75"
 
-private@^0.1.6, private@^0.1.7:
+private@^0.1.6, private@^0.1.7, private@^0.1.8:
   version "0.1.8"
   resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
 
@@ -4772,6 +4933,10 @@ prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
+propagate@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/propagate/-/propagate-1.0.0.tgz#00c2daeedda20e87e3782b344adba1cddd6ad709"
+
 proxy-addr@~2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.3.tgz#355f262505a621646b3130a728eb647e22055341"
@@ -4848,6 +5013,10 @@ qs@6.5.1, qs@~6.5.1:
   version "6.5.1"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
 
+qs@^6.5.1:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+
 qs@~6.4.0:
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
@@ -5127,6 +5296,12 @@ redux-devtools-extension@^2.13.2:
   version "2.13.2"
   resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.2.tgz#e0f9a8e8dfca7c17be92c7124958a3b94eb2911d"
 
+redux-mock-store@^1.5.3:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.3.tgz#1f10528949b7ce8056c2532624f7cafa98576c6d"
+  dependencies:
+    lodash.isplainobject "^4.0.6"
+
 redux-thunk@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"
@@ -5145,8 +5320,8 @@ referrer-policy@1.1.0:
   resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.1.0.tgz#35774eb735bf50fb6c078e83334b472350207d79"
 
 regenerate@^1.2.1:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.3.tgz#0c336d3980553d755c39b586ae3b20aa49c82b7f"
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
 
 regenerator-runtime@^0.10.5:
   version "0.10.5"
@@ -5383,6 +5558,10 @@ safe-regex@^1.1.0:
   dependencies:
     ret "~0.1.10"
 
+samsam@1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
+
 schema-utils@^0.4.2:
   version "0.4.5"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e"
@@ -5396,7 +5575,7 @@ semver-diff@^2.0.0:
   dependencies:
     semver "^5.0.3"
 
-"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0:
+"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab"
 
@@ -5512,6 +5691,18 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
 
+sinon@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-6.0.0.tgz#f26627e4830dc34279661474da2c9e784f166215"
+  dependencies:
+    "@sinonjs/formatio" "^2.0.0"
+    diff "^3.5.0"
+    lodash.get "^4.4.2"
+    lolex "^2.4.2"
+    nise "^1.3.3"
+    supports-color "^5.4.0"
+    type-detect "^4.0.8"
+
 slash@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
@@ -5814,6 +6005,12 @@ stylis@^3.0.0, stylis@^3.5.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.0.tgz#016fa239663d77f868fef5b67cf201c4b7c701e1"
 
+supports-color@5.4.0, supports-color@^5.4.0:
+  version "5.4.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54"
+  dependencies:
+    has-flag "^3.0.0"
+
 supports-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
@@ -5882,6 +6079,10 @@ term-size@^1.2.0:
   dependencies:
     execa "^0.7.0"
 
+text-encoding@^0.6.4:
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
+
 text-table@~0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@@ -6017,6 +6218,10 @@ type-check@~0.3.2:
   dependencies:
     prelude-ls "~1.1.2"
 
+type-detect@^4.0.0, type-detect@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+
 type-is@~1.6.15, type-is@~1.6.16:
   version "1.6.16"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194"