From 24e29486f85fc288ab99716d06a47b986d958ade Mon Sep 17 00:00:00 2001 From: Segun Date: Sun, 10 Dec 2017 12:37:40 +0100 Subject: [PATCH] feature(delete-book): refactor utitlity functions --- .babelrc | 2 +- .eslintrc | 35 ++-- README.md | 59 ++++--- client/__tests__/actions/authActions.spec.js | 67 +++++++- .../components/Dashboard/index.spec.jsx | 17 +- .../components/common/Modal.spec.jsx | 2 +- client/__tests__/setupTest.js | 1 + .../__tests__/utils/requestImageUrl.spec.js | 18 +++ client/__tests__/utils/saveLocally.spec.js | 17 +- client/actions/api.js | 5 +- client/actions/authActions/login.js | 7 +- .../authActions/requestResetPassword.js | 5 +- client/actions/authActions/resetPassword.js | 5 +- client/actions/authActions/signup.js | 7 +- client/utils/saveLocally.js | 13 +- package-lock.json | 150 ++++++++++++++++++ package.json | 2 + server/constants/index.js | 2 +- server/middleware/maxBorrowed.js | 45 ------ server/middleware/shouldBorrowBook.js | 46 ++++++ server/middleware/validateInput.js | 3 +- server/routes/index.js | 6 +- .../test/middleware/shouldBorrowBook.spec.js | 59 +++++++ webpack.common.config.js | 8 +- 24 files changed, 462 insertions(+), 119 deletions(-) create mode 100644 client/__tests__/utils/requestImageUrl.spec.js delete mode 100644 server/middleware/maxBorrowed.js create mode 100644 server/middleware/shouldBorrowBook.js create mode 100644 server/test/middleware/shouldBorrowBook.spec.js diff --git a/.babelrc b/.babelrc index e9cba25..69a163e 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,6 @@ { "presets": ["stage-2", "env", "react"], - "plugins": ["transform-object-rest-spread"], + "plugins": ["transform-object-rest-spread", "transform-class-properties"], "env": { "production": { "plugins": [ diff --git a/.eslintrc b/.eslintrc index 18fe5e9..e08f833 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,14 +8,15 @@ "mocha": true, "jest": true }, + "parser": "babel-eslint", "parserOptions": { - "ecmaVersion": 6, - "ecmaFeatures": { "jsx": true } + "ecmaVersion": 8, + "ecmaFeatures": { "jsx": true, "classes": true } }, "settings": { "import/resolver": { "node": { - "extensions": [".js",".jsx"] + "extensions": [".js", ".jsx"] } } }, @@ -34,19 +35,25 @@ "no-shadow": ["error", { "allow": ["req", "res", "err"] }], "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", - "valid-jsdoc": ["error", { - "requireReturn": true, - "requireReturnType": true, - "requireParamDescription": false, - "requireReturnDescription": true - }], + "valid-jsdoc": [ + "error", + { + "requireReturn": true, + "requireReturnType": true, + "requireParamDescription": false, + "requireReturnDescription": true + } + ], "class-methods-use-this": 0, - "require-jsdoc": ["error", { + "require-jsdoc": [ + "error", + { "require": { - "FunctionDeclaration": true, - "MethodDefinition": true, - "ClassDeclaration": true + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true } - }] + } + ] } } diff --git a/README.md b/README.md index 6e8ec44..7d6bb95 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,28 @@ +# helloBooks + [![License](http://img.shields.io/badge/license-MIT-blue.svg)](http://opensource.org/licenses/MIT) [![Build Status](https://travis-ci.org/segunolalive/helloBooks.svg?branch=development)](https://travis-ci.org/segunolalive/helloBooks) [![Coverage Status](https://coveralls.io/repos/github/segunolalive/helloBooks/badge.svg?branch=development)](https://coveralls.io/github/segunolalive/helloBooks?branch=development) [![Code Climate](https://codeclimate.com/github/segunolalive/helloBooks/badges/gpa.svg)](https://codeclimate.com/github/segunolalive/helloBooks?branch=development) -# helloBooks +## A Library app -### A Library app Hello books is an application that provides users with access to books from wherever they are. Being a virtual library, users can borrow and read their favorite books using any device. HelloBooks exposes RESTful API endpoints such that anyone can customize their method of consuming the resources. -#### Built With +### Built With + * [NodeJS](https://nodejs.org/en/) - A JavaScript runtime built on Chrome's V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient. * [PostgreSQL](https://www.postgresql.org/) - A powerful, open source object-relational database system. * [Sequelize](http://docs.sequelizejs.com/) - A promise-based ORM for Node.js v4 and up. It supports the dialects PostgreSQL, MySQL, SQLite and MSSQL and features solid transaction support, relations, read replication and more. * [ExpressJS](http://expressjs.com/) - Fast, unopinionated, minimalist web framework for Node.js. * [Reactjs](https://reactjs.org/) - A declarative component-based JavaScript library for building user interfaces - #### Getting Started -``` + +```markdown # Clone your fork of this repository # Ensure NodeJS, PostgreSQL and Sequelize cli are globally installed @@ -51,30 +53,47 @@ npm run start ``` #### Features -- Authentication is via [**JSON Web Tokens**](https://jwt.io/) -- Login/Sign up to gain access to routes -- A library of books from different categories -- Ability to borrow books repeatedly -- Track your reading/borrowing history -- Admin access to add and modify book details + +* Authentication is via [**JSON Web Tokens**](https://jwt.io/) +* Login/Sign up to gain access to routes +* A library of books from different categories +* Ability to borrow books repeatedly +* Track your reading/borrowing history +* Admin access to add and modify book details #### API Documentation -- +* #### Testing -Run `npm test` + +For client-side tests, run `npm run test:client` + +For server-side tests, run `npm run test:server` + +For both, run `npm test` + +For end to end tests, start by running `npm run e2e-setup` + +Then start the client by running `npm run start:client`. + +In another terminal window, run `npm run start:server:e2e` to start the application server in test-mode. + +In a third terminal window run `npm run e2e:server` to start the selenium server. + +In a fourth terminal window run `npm run test:e2e` #### Contributing + Hello books is open source and contributions are highly welcomed. If you would like to contribute, follow the instructions below. -- Fork this project. -- Checkout a new branch -- Make your changes and commit. -- Keep commit messages atomic. -- Raise a pull request against development. +* Fork this project. +* Checkout a new branch +* Make your changes and commit. +* Keep commit messages atomic. +* Raise a pull request against development. **NB:** All Pull requests must be made against development branch. Pull Requests against master would be rejected. @@ -84,8 +103,10 @@ See project wiki for coding style guide, commit message, pull request and branch #### Acknowledgments -* Andela Fellowship (https://andela.com/) +* [Andela Fellowship](https://andela.com/) --- + #### License + MIT License diff --git a/client/__tests__/actions/authActions.spec.js b/client/__tests__/actions/authActions.spec.js index c890862..4325e7c 100644 --- a/client/__tests__/actions/authActions.spec.js +++ b/client/__tests__/actions/authActions.spec.js @@ -5,8 +5,13 @@ import configureMockStore from 'redux-mock-store'; import mockData from '../__mocks__/mockData'; import mockLocalStorage from '../__mocks__/mockLocalStorage'; import { login } from '../../actions/authActions/login'; +import logout from '../../actions/authActions/logout'; import { signUp } from '../../actions/authActions/signup'; +import requestResetPassword from '../../actions/authActions/requestResetPassword'; +import resetPassword from '../../actions/authActions/resetPassword'; import actionTypes from '../../actions/actionTypes'; +import notify from '../__mocks__/notify'; +import { request } from 'https'; const middleware = [thunk]; const mockStore = configureMockStore(middleware); @@ -76,7 +81,7 @@ describe('Auth Actions', () => { }); it('creates AUTH_LOADING on signup failure', () => { const { authResponse } = mockData; - moxios.stubRequest('/api/v1/users/signin', { + moxios.stubRequest('/api/v1/users/signup', { status: 400, response: authResponse }); @@ -85,9 +90,67 @@ describe('Auth Actions', () => { { type: actionTypes.AUTH_LOADING, state: false } ]; const store = mockStore({}); - return store.dispatch(login({})).then(() => { + return store.dispatch(signUp({})).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); }); + + describe('logout', () => { + it('creates LOGOUT when logout action is successful', (done) => { + const store = mockStore({}); + const expectedActions = [{ type: actionTypes.LOGOUT }]; + store.dispatch(logout()); + expect(store.getActions()).toEqual(expectedActions); + done(); + }); + }); + + describe('requestResetPassword', () => { + it('provides a notification on success', () => { + moxios.stubRequest('/api/v1/users/forgot-password', { + status: 200, + response: mockData.authResponse + }); + expect(notify.success).toHaveBeenCalled(); + }); + + it("provides a notification on success", () => { + window.requestResetPassword = () => () => Promise.resolve(1); + requestResetPassword("password", "token"); + expect(notify.success).toHaveBeenCalled(); + }); + + it('provides a notification on failure', () => { + moxios.stubRequest('/api/v1/users/forgot-password', { + status: 500, + response: mockData.authResponse + }); + expect(notify.error).toHaveBeenCalled(); + }); + }); + + describe('resetPassword', () => { + it('provides a notification on success', () => { + moxios.stubRequest('/api/v1/users/reset-password/1234yyjhkopi123', { + status: 200, + response: mockData.authResponse + }); + expect(notify.success).toHaveBeenCalled(); + }); + + it('provides a notification on success', () => { + window.resetPassword = () => () => Promise.resolve(1); + resetPassword('password', 'token'); + expect(notify.success).toHaveBeenCalled(); + }); + + it('provides a notification on failure', () => { + moxios.stubRequest('/api/v1/users/reset-password/1234yyjhkopi123', { + status: 500, + response: mockData.authResponse + }); + expect(notify.error).toHaveBeenCalled(); + }); + }); }); diff --git a/client/__tests__/components/Dashboard/index.spec.jsx b/client/__tests__/components/Dashboard/index.spec.jsx index 6168154..ce1708e 100644 --- a/client/__tests__/components/Dashboard/index.spec.jsx +++ b/client/__tests__/components/Dashboard/index.spec.jsx @@ -15,9 +15,10 @@ const store = mockStore(mockStoreData); let props = { fetchBorrowedBooks: jest.fn(), returnBook: jest.fn(), + readBook: jest.fn(), ...mockStoreData.authReducer, ...mockStoreData.bookReducer.borrowedBooks, - fetchingBorrowedBooks: false, + fetchingBorrowedBooks: false }; const setUp = () => (shallow()); @@ -53,6 +54,20 @@ describe('Dashboard Component', () => { expect(componentDidMountSpy).toHaveBeenCalledTimes(1); }); + it("should call the handleReturnBook method", () => { + const wrapper = shallow(); + const handleReturnBookSpy = jest.spyOn(wrapper.instance(), "handleReturnBook"); + wrapper.instance().handleReturnBook(1); + expect(handleReturnBookSpy).toHaveBeenCalledTimes(1); + }); + + it("should call the readBook method", () => { + const wrapper = shallow(); + const readBookSpy = jest.spyOn(wrapper.instance(), "readBook"); + wrapper.instance().readBook(1); + expect(readBookSpy).toHaveBeenCalledTimes(1); + }); + it('should redirect to login page if user is not logged in', () => { props = { ...props, isLoggedIn: false }; const wrapper = shallow(); diff --git a/client/__tests__/components/common/Modal.spec.jsx b/client/__tests__/components/common/Modal.spec.jsx index c232ecf..3a34ee3 100644 --- a/client/__tests__/components/common/Modal.spec.jsx +++ b/client/__tests__/components/common/Modal.spec.jsx @@ -22,7 +22,7 @@ describe('Modal', () => { }); it("calls modalAction prop function when a confirm button is clicked", () => { - const confirmButton = wrapper.find("button").at(1); + const confirmButton = wrapper.find("button").at(0); confirmButton.simulate("click"); expect(wrapper.instance().props.modalAction).toHaveBeenCalled(); }); diff --git a/client/__tests__/setupTest.js b/client/__tests__/setupTest.js index 5f25d79..1a66137 100644 --- a/client/__tests__/setupTest.js +++ b/client/__tests__/setupTest.js @@ -6,6 +6,7 @@ global.$ = $; $.prototype.sideNav = () => { }; $.prototype.material_select = () => { }; $.prototype.modal = () => { }; +$.prototype.ready = fn => fn(); global.Materialize = { toast: () => {} diff --git a/client/__tests__/utils/requestImageUrl.spec.js b/client/__tests__/utils/requestImageUrl.spec.js new file mode 100644 index 0000000..38d7130 --- /dev/null +++ b/client/__tests__/utils/requestImageUrl.spec.js @@ -0,0 +1,18 @@ +import requestImageUrl from '../../utils/requestImageUrl'; + +describe('requestImageUrl', () => { + it('returns the url if no configuration object is passed', () => { + const baseUrl = "localhost:300"; + const result = requestImageUrl(baseUrl); + expect(result).toEqual(baseUrl); + }); + + it('adds width and height configuration the url', () => { + const baseUrl = "localhost:300"; + const result = requestImageUrl(baseUrl, {width: 10, height: 10 }); + expect(result).not.toEqual(baseUrl); + expect(result).toContain('w_10'); + expect(result).toContain("h_10"); + }); +}); + diff --git a/client/__tests__/utils/saveLocally.spec.js b/client/__tests__/utils/saveLocally.spec.js index 681a35c..9135081 100644 --- a/client/__tests__/utils/saveLocally.spec.js +++ b/client/__tests__/utils/saveLocally.spec.js @@ -16,6 +16,14 @@ describe('saveState', () => { expect(localStorage.getItem('state').id).toEqual(2); }, 1000); }); + + it('fails silently', () => { + localStorage.setItem = () => {throw new Error('something broke')}; + saveState(state); + setTimeout(() => { + expect(localStorage.getItem("state").id).toEqual(false); + }, 1000); + }) }); describe('loadState', () => { @@ -23,7 +31,14 @@ describe('loadState', () => { const loadedState = loadState(); setTimeout(() => { expect(loadedState.id).toEqual(2); - console.log(localStorage); + }, 1000); + }); + + it('returns false if state is null', () => { + const nullState = null + saveState(nullState); + setTimeout(() => { + expect(loadState()).toEqual(false); }, 5000); }); }); diff --git a/client/actions/api.js b/client/actions/api.js index 7366e22..9501dd2 100644 --- a/client/actions/api.js +++ b/client/actions/api.js @@ -1,8 +1,7 @@ let api = '/api/v1'; -if (process.env.NODE_ENV === ('development' || 'test')) { - api = 'http://localhost:4000/api/v1'; -} +api = process.env.NODE_ENV === ('development' || 'test') ? + `http://localhost:4000${api}` : api; /** * api url diff --git a/client/actions/authActions/login.js b/client/actions/authActions/login.js index 7874c8a..1a2c2c8 100644 --- a/client/actions/authActions/login.js +++ b/client/actions/authActions/login.js @@ -42,12 +42,9 @@ export const login = data => (dispatch) => { dispatch(authLoading(false)); notify.success(response.data.message); return response.data; - }, (error) => { - notify.error(error.response.data.message); - return dispatch(authLoading(false)); }) - .catch(() => { - notify.error('Something terrible happened. We\'ll fix that'); + .catch((error) => { + notify.error(error.response.data.message); return dispatch(authLoading(false)); }); }; diff --git a/client/actions/authActions/requestResetPassword.js b/client/actions/authActions/requestResetPassword.js index 260608a..9b549c5 100644 --- a/client/actions/authActions/requestResetPassword.js +++ b/client/actions/authActions/requestResetPassword.js @@ -9,9 +9,8 @@ import notify from '../notify'; */ const requestResetPassword = email => () => ( axios.post(`${API}/users/forgot-password`, { email }) - .then(response => notify.success(response.data.message), - error => notify.error(error.response.data.message) - ).catch(err => notify.error(err.response.data.message)) + .then(response => notify.success(response.data.message) + ).catch(error => notify.error(error.response.data.message)) ); export default requestResetPassword; diff --git a/client/actions/authActions/resetPassword.js b/client/actions/authActions/resetPassword.js index c9e11e6..ac39bfd 100644 --- a/client/actions/authActions/resetPassword.js +++ b/client/actions/authActions/resetPassword.js @@ -10,9 +10,8 @@ import notify from '../notify'; */ const resetPassword = (password, token) => () => ( axios.put(`${API}/users/reset-password/${token}`, { password }) - .then(response => notify.success(response.data.message), - error => notify.error(error.response.data.message) - ).catch(err => notify.error(err.response.data.message)) + .then(response => notify.success(response.data.message) + ).catch(error => notify.oror(error.response.data.message)) ); export default resetPassword; diff --git a/client/actions/authActions/signup.js b/client/actions/authActions/signup.js index 05974b1..e25bf63 100644 --- a/client/actions/authActions/signup.js +++ b/client/actions/authActions/signup.js @@ -40,12 +40,9 @@ export const signUp = data => (dispatch) => { dispatch(authLoading(false)); notify.success(response.data.message); return response.data; - }, (error) => { - notify.error(error.response.data.message); - return dispatch(authLoading(false)); }) - .catch(() => { - notify.error('Something terrible happened. We\'ll fix that'); + .catch((error) => { + notify.error(error.response.data.message); return dispatch(authLoading(false)); }); }; diff --git a/client/utils/saveLocally.js b/client/utils/saveLocally.js index ead0d0d..d0d1d15 100644 --- a/client/utils/saveLocally.js +++ b/client/utils/saveLocally.js @@ -1,7 +1,8 @@ /** * saves application state to disk - * @param {Object} state application state - * @return {undefined} wites to disk + * @param {Object} state application state + * + * @return {undefined|Boolean} writes to disk */ export const saveState = (state) => { try { @@ -14,15 +15,13 @@ export const saveState = (state) => { /** * loads state from disk - * @return {Object} State Object + * @return {Object|Boolean} State Object or false */ export const loadState = () => { try { const serializedState = localStorage.getItem('state'); - if (serializedState === null) { - return false; - } - return JSON.parse(serializedState); + return serializedState === null ? + false : JSON.parse(serializedState); } catch (e) { return false; } diff --git a/package-lock.json b/package-lock.json index c4c7dae..fb7ad50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,138 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@babel/code-frame": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0-beta.31.tgz", + "integrity": "sha512-yd7CkUughvHQoEahQqcMdrZw6o/6PwUxiRkfZuVDVHCDe77mysD/suoNyk5mK6phTnRW1kyIbPHyCJgxw++LXg==", + "requires": { + "chalk": "2.3.0", + "esutils": "2.0.2", + "js-tokens": "3.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz", + "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==", + "requires": { + "color-convert": "1.9.0" + } + }, + "chalk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.0.tgz", + "integrity": "sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==", + "requires": { + "ansi-styles": "3.2.0", + "escape-string-regexp": "1.0.5", + "supports-color": "4.5.0" + } + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=" + }, + "supports-color": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "requires": { + "has-flag": "2.0.0" + } + } + } + }, + "@babel/helper-function-name": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.0.0-beta.31.tgz", + "integrity": "sha512-c+DAyp8LMm2nzSs2uXEuxp4LYGSUYEyHtU3fU57avFChjsnTmmpWmXj2dv0yUxHTEydgVAv5fIzA+4KJwoqWDA==", + "requires": { + "@babel/helper-get-function-arity": "7.0.0-beta.31", + "@babel/template": "7.0.0-beta.31", + "@babel/traverse": "7.0.0-beta.31", + "@babel/types": "7.0.0-beta.31" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-beta.31.tgz", + "integrity": "sha512-m7rVVX/dMLbbB9NCzKYRrrFb0qZxgpmQ4Wv6y7zEsB6skoJHRuXVeb/hAFze79vXBbuD63ci7AVHXzAdZSk9KQ==", + "requires": { + "@babel/types": "7.0.0-beta.31" + } + }, + "@babel/template": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.0.0-beta.31.tgz", + "integrity": "sha512-97IRmLvoDhIDSQkqklVt3UCxJsv0LUEVb/0DzXWtc8Lgiyxj567qZkmTG9aR21CmcJVVIvq2Y/moZj4oEpl5AA==", + "requires": { + "@babel/code-frame": "7.0.0-beta.31", + "@babel/types": "7.0.0-beta.31", + "babylon": "7.0.0-beta.31", + "lodash": "4.17.4" + }, + "dependencies": { + "babylon": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.31.tgz", + "integrity": "sha512-6lm2mV3S51yEnKmQQNnswoABL1U1H1KHoCCVwdwI3hvIv+W7ya4ki7Aw4o4KxtUHjNKkK5WpZb22rrMMOcJXJQ==" + } + } + }, + "@babel/traverse": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.0.0-beta.31.tgz", + "integrity": "sha512-3N+VJW+KlezEjFBG7WSYeMyC5kIqVLPb/PGSzCDPFcJrnArluD1GIl7Y3xC7cjKiTq2/JohaLWHVPjJWHlo9Gg==", + "requires": { + "@babel/code-frame": "7.0.0-beta.31", + "@babel/helper-function-name": "7.0.0-beta.31", + "@babel/types": "7.0.0-beta.31", + "babylon": "7.0.0-beta.31", + "debug": "3.1.0", + "globals": "10.4.0", + "invariant": "2.2.2", + "lodash": "4.17.4" + }, + "dependencies": { + "babylon": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.31.tgz", + "integrity": "sha512-6lm2mV3S51yEnKmQQNnswoABL1U1H1KHoCCVwdwI3hvIv+W7ya4ki7Aw4o4KxtUHjNKkK5WpZb22rrMMOcJXJQ==" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "globals": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-10.4.0.tgz", + "integrity": "sha512-uNUtxIZpGyuaq+5BqGGQHsL4wUlJAXRqOm6g3Y48/CWNGTLONgBibI0lh6lGxjR2HljFYUfszb+mk4WkgMntsA==" + } + } + }, + "@babel/types": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0-beta.31.tgz", + "integrity": "sha512-exAHB+NeFGxkfQ5dSUD03xl3zYGneeSk2Mw2ldTt/nTvYxuDiuSp3DlxgUBgzbdTFG4fbwPk0WtKWOoTXCmNGg==", + "requires": { + "esutils": "2.0.2", + "lodash": "4.17.4", + "to-fast-properties": "2.0.0" + }, + "dependencies": { + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + } + } + }, "@firebase/app": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.1.2.tgz", @@ -568,6 +700,24 @@ "source-map": "0.5.7" } }, + "babel-eslint": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-8.0.3.tgz", + "integrity": "sha512-7D4iUpylEiKJPGbeSAlNddGcmA41PadgZ6UAb6JVyh003h3d0EbZusYFBR/+nBgqtaVJM2J2zUVa3N0hrpMH6g==", + "requires": { + "@babel/code-frame": "7.0.0-beta.31", + "@babel/traverse": "7.0.0-beta.31", + "@babel/types": "7.0.0-beta.31", + "babylon": "7.0.0-beta.31" + }, + "dependencies": { + "babylon": { + "version": "7.0.0-beta.31", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.31.tgz", + "integrity": "sha512-6lm2mV3S51yEnKmQQNnswoABL1U1H1KHoCCVwdwI3hvIv+W7ya4ki7Aw4o4KxtUHjNKkK5WpZb22rrMMOcJXJQ==" + } + } + }, "babel-generator": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.0.tgz", diff --git a/package.json b/package.json index 4ca138d..fd32c03 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start:client": "webpack-dev-server --hot --config ./webpack.dev.config.js", "start:server": "nodemon server/bin/www --exec babel-node", "start:server:debug": "nodemon --inspect server/bin/www --exec babel-node", + "coverage": "NODE_ENV=test nyc report --reporter=text-lcov | coveralls", "test": "npm run test:server && npm run test:client", "migrate:test": "sequelize db:migrate:undo:all --env test && sequelize db:migrate --env test", "seed:tables:test": "cross-env NODE_ENV=test babel-node server/seeders/user.js && babel-node server/seeders/bookCategory.js && babel-node server/seeders/book.js", @@ -61,6 +62,7 @@ "axios": "^0.16.2", "babel-cli": "^6.24.1", "babel-core": "^6.25.0", + "babel-eslint": "^8.0.3", "babel-loader": "^7.1.1", "babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-transform-object-rest-spread": "^6.26.0", diff --git a/server/constants/index.js b/server/constants/index.js index d5b96a8..34d5ed2 100644 --- a/server/constants/index.js +++ b/server/constants/index.js @@ -1,4 +1,4 @@ export default { - DEFAULT_LIMIT: 10, + DEFAULT_LIMIT: 9, DEFAULT_OFFSET: 0, }; diff --git a/server/middleware/maxBorrowed.js b/server/middleware/maxBorrowed.js deleted file mode 100644 index f7d67fa..0000000 --- a/server/middleware/maxBorrowed.js +++ /dev/null @@ -1,45 +0,0 @@ -import { User, Book } from '../models'; -import { maximumBorrrow } from '../helpers/borrowingLimits'; - -/** - * check if user should be allowed to borrow a new book - * @param {Object} req express http request object - * @param {Object} res express http response object - * @param {Function} next calls the next middleware function in the stack - * @return {Undefined} responds with an http response or calls the next - * middleware function - */ -const shouldBorrow = (req, res, next) => { - User.findOne({ - where: { id: req.user.id }, - include: [{ model: Book }] - }) - .then((user) => { - const unreturnedBooks = user.Books.filter( - book => book.BorrowedBook.returned === false - ); - let canBorrow; - switch (user.membershipType) { - case 'bronze': - canBorrow = unreturnedBooks.length <= maximumBorrrow.bronze; - break; - case 'silver': - canBorrow = unreturnedBooks.length <= maximumBorrrow.silver; - break; - case 'gold': - canBorrow = unreturnedBooks.length <= maximumBorrrow.gold; - break; - default: - canBorrow = false; - } - if (canBorrow) next(); - else { - res.status(403).send({ - message: `You have reached your borrowing limit. - Return some books or upgrade your account type to borrow more`, - }); - } - }); -}; - -export default shouldBorrow; diff --git a/server/middleware/shouldBorrowBook.js b/server/middleware/shouldBorrowBook.js new file mode 100644 index 0000000..648dbe6 --- /dev/null +++ b/server/middleware/shouldBorrowBook.js @@ -0,0 +1,46 @@ +import { User, Book } from '../models'; +import { maximumBorrrow } from '../helpers/borrowingLimits'; + + +export const canBorrowBook = (numberBorrowed, membershipType) => { + switch (membershipType) { + case 'bronze': + return numberBorrowed < maximumBorrrow.bronze; + case 'silver': + return numberBorrowed < maximumBorrrow.silver; + case 'gold': + return numberBorrowed < maximumBorrrow.gold; + default: + return false; + } +}; + +/** + * check if user should be allowed to borrow a new book + * @param {Object} req express http request object + * @param {Object} res express http response object + * @param {Function} next calls the next middleware in the stack + * @return {Function|Object} http response object or calls the next + * middleware function + */ +const shouldBorrowBook = (req, res, next) => User.findOne({ + where: { id: req.user.id }, + include: [{ model: Book }] +}) + .then((user) => { + const unreturnedBooks = user.Books.filter( + book => book.BorrowedBook.returned === false + ); + const canBorrow = canBorrowBook(unreturnedBooks.length, + user.membershipType); + + return canBorrow ? next() : + res.status(403).send({ + message: `You have reached your borrowing limit. + Return some books or upgrade your account type to borrow more`, + }); + }) + .catch(error => next(error)); + + +export default shouldBorrowBook; diff --git a/server/middleware/validateInput.js b/server/middleware/validateInput.js index 26fdd53..21da70f 100644 --- a/server/middleware/validateInput.js +++ b/server/middleware/validateInput.js @@ -176,7 +176,8 @@ export default { addBook(req, res, next) { req.body = deleteEmptyFields(trimFields(req.body)); req.body.categoryId = (!Number.isNaN(req.body.categoryId) && - Number.isInteger(Number(req.body.categoryId))) || undefined; + Number.isInteger(Number(req.body.categoryId)) && + Number(req.body.categoryId)) || undefined; next(); }, diff --git a/server/routes/index.js b/server/routes/index.js index 4de893d..da7f835 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -5,7 +5,7 @@ import bookController from '../controllers/bookController'; import transactionController from '../controllers/transactionController'; import authenticate from '../middleware/authenticate'; -import shouldBorrow from '../middleware/maxBorrowed'; +import shouldBorrowBook from '../middleware/shouldBorrowBook'; import ensureIsAdmin from '../middleware/ensureIsAdmin'; import validateInput from '../middleware/validateInput'; import prepareGoogleAuth from '../middleware/prepareGoogleAuth'; @@ -36,6 +36,7 @@ router.get('/', (req, res) => res.status(200).send({ .get('/books/category', bookController.getBookCategories) .get('/books/:id', bookController.getBook) .get('/books', validateLimitAndOffset, bookController.getBooks) + // Protected routes .put( '/users/reset-password/:token', @@ -53,7 +54,7 @@ router.get('/', (req, res) => res.status(200).send({ '/users/:id/books', authenticate, validateInput.validateId, - shouldBorrow, + shouldBorrowBook, bookController.borrowBook ) .put( @@ -79,6 +80,7 @@ router.get('/', (req, res) => res.status(200).send({ authenticate, userController.updateUserInfo ) + // Admin-specific routes .post( '/books/category', diff --git a/server/test/middleware/shouldBorrowBook.spec.js b/server/test/middleware/shouldBorrowBook.spec.js new file mode 100644 index 0000000..97a1851 --- /dev/null +++ b/server/test/middleware/shouldBorrowBook.spec.js @@ -0,0 +1,59 @@ +import supertest from "supertest"; +import { assert, expect } from "chai"; +import app from "../../app"; +import shouldBorrowBook, { canBorrowBook } + from "../../middleware/shouldBorrowBook"; +import { User } from '../../models'; +import mock from "../mock/mock"; + +const server = supertest.agent(app); + +describe('shouldBorrowBook', () => { + let jwtToken; + before((done) => { + server + .post('/api/v1/users/signin') + .send(mock.adminUser) + .end((err, res) => { + jwtToken = res.body.token; + done(); + }); + }); + + it("handles server errors", () => { + User.findOne = () => Promise.reject(1); + server + .post('/api/v1/users/1/books') + .set('X-ACCESS-TOKEN', jwtToken) + .send({ id: 4 }) + .expect(500) + .end((err, res) => { + assert.equal(res.status, 500); + assert.equal(res.body.message, + 'Something went wrong. Internal server error'); + done(); + }); + }); + + describe("#canBorrowBook helper", () => { + it("returns false for unknown membership type", () => { + const result = canBorrowBook(5, null); + expect(result).to.equal(false); + }); + + it("checks for bronze membership type", () => { + const result = canBorrowBook(5, "bronze"); + expect(result).to.equal(false); + }); + + it("checks for silver membership type", () => { + const result = canBorrowBook(5, "silver"); + expect(result).to.equal(true); + }); + + it("checks for gold membership type", () => { + const result = canBorrowBook(5, "gold"); + expect(result).to.equal(true); + }); + }); +}); diff --git a/webpack.common.config.js b/webpack.common.config.js index 885b2d5..01ca253 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -24,10 +24,11 @@ module.exports = { rules: [ { test: /\.jsx?$/, - exclude: ['node_modules', 'server', 'test', 'dist'], + exclude: ['node_modules', 'server', 'test', '__tests__', 'dist'], loader: 'babel-loader', query: { - presets: ['react', 'env'], + presets: ['stage-2', 'react', 'env'], + plugins: ['transform-class-properties'] }, }, { @@ -61,9 +62,6 @@ module.exports = { plugins: [ HtmlWebpackPluginConfig, new webpack.DefinePlugin({ - 'process.env': { - NODE_ENV: JSON.stringify('development') - }, GOOGLE_CLIENT_ID: JSON.stringify('701806023399-vgqondt26qh10vcuei77r7' + 'nsbcd8oa8k.apps.googleusercontent.com'), CLOUDINARY_API_BASE: JSON.stringify(