diff --git a/src/controllers/bookmark.controller.js b/src/controllers/bookmark.controller.js new file mode 100644 index 0000000..3bd2b0d --- /dev/null +++ b/src/controllers/bookmark.controller.js @@ -0,0 +1,82 @@ +import { + createBookmark, + removeBookmark, + getBookmarks +} from '../services/bookmark.service'; +import Helper from '../services/helper'; + +export default { + /** + * @method createBookmark + * Handles the logic for adding an article to a user's bookmark + * Route: POST api/v1/articles/:slug/bookmarks + * @param {object} request + * @param {object} response + * @param {function} next + * @returns {object|function} API Response object or next method + */ + + async createBookmark(request, response, next) { + try { + const bookmarked = await createBookmark( + request.articleId, + request.user.id + ); + if (bookmarked) { + return Helper.successResponse(response, 201, { + message: `Article added to bookmarks` + }); + } + return Helper.failResponse(response, 409, { + message: `Article is already in your bookmarks` + }); + } catch (error) { + next(error); + } + }, + + /** + * @method getUserBookmarks + * Route: GET api/v1/articles/bookmarks + * Handles the logic for fetching all a user's bookmark + * @param {object} request + * @param {object} response + * @param {function} next + * @returns {object|function} API Response object or next method + */ + + async getUserBookmarks(request, response, next) { + try { + const bookmarks = await getBookmarks(request.user.id); + return Helper.successResponse(response, 200, bookmarks); + } catch (error) { + next(error); + } + }, + + /** + * @method removeUserBookmark + * Route: DELETE api/v1/articles/:slug/bookmarks + * Handles the logic for removing an article from a user's bookmark + * @param {object} request + * @param {object} response + * @param {function} next + * @returns {object|function} API Response object or next method + */ + + async removeUserBookmark(request, response, next) { + try { + const removed = await removeBookmark(request.articleId, request.user.id); + if (removed) { + return Helper.successResponse(response, 200, { + message: `Article has been removed from your bookmarks` + }); + } + return Helper.failResponse(response, 404, { + message: `Article is not present in your bookmarks` + }); + } catch (error) { + next(error); + } + } +}; diff --git a/src/db/migrations/20190715093702-create-bookmark.js b/src/db/migrations/20190715093702-create-bookmark.js new file mode 100644 index 0000000..8a4090f --- /dev/null +++ b/src/db/migrations/20190715093702-create-bookmark.js @@ -0,0 +1,34 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Bookmarks', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + userId: { + type: Sequelize.INTEGER + }, + articleId: { + type: Sequelize.INTEGER + }, + isDeleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: queryInterface => { + return queryInterface.dropTable('Bookmarks'); + } +}; diff --git a/src/db/migrations/create-article.js b/src/db/migrations/create-article.js index 524482f..476aed1 100644 --- a/src/db/migrations/create-article.js +++ b/src/db/migrations/create-article.js @@ -28,6 +28,16 @@ module.exports = { type: Sequelize.STRING, allowNull: false }, + likesCount: { + type: Sequelize.INTEGER, + allowNull: true, + default: 0 + }, + viewsCount: { + type: Sequelize.INTEGER, + allowNull: true, + default: 0 + }, body: { type: Sequelize.TEXT, allowNull: false diff --git a/src/db/models/article.js b/src/db/models/article.js index 0026f92..53e71a9 100644 --- a/src/db/models/article.js +++ b/src/db/models/article.js @@ -48,6 +48,16 @@ export default (sequelize, DataTypes) => { args: false } }, + likesCount: { + type: DataTypes.INTEGER, + allowNull: true, + default: 0 + }, + viewsCount: { + type: DataTypes.INTEGER, + allowNull: true, + default: 0 + }, averageRating: { type: DataTypes.INTEGER, default: 0 @@ -88,6 +98,12 @@ export default (sequelize, DataTypes) => { foreignKey: 'userId', as: 'author' }); + Article.belongsToMany(models.User, { + through: 'Bookmark', + as: 'bookmarks', + foreignKey: 'articleId', + otherKey: 'userId' + }); }; return Article; }; diff --git a/src/db/models/bookmark.js b/src/db/models/bookmark.js new file mode 100644 index 0000000..8a082c4 --- /dev/null +++ b/src/db/models/bookmark.js @@ -0,0 +1,19 @@ +export default (sequelize, DataTypes) => { + const Bookmark = sequelize.define( + 'Bookmark', + { + userId: DataTypes.INTEGER, + articleId: DataTypes.INTEGER, + isDeleted: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + } + }, + {} + ); + Bookmark.associate = () => { + // associations can be defined here + }; + return Bookmark; +}; diff --git a/src/db/models/user.js b/src/db/models/user.js index 5d7d0be..8160f41 100644 --- a/src/db/models/user.js +++ b/src/db/models/user.js @@ -115,6 +115,12 @@ export default (sequelize, DataTypes) => { foreignKey: 'userId', as: 'articles' }); + User.belongsToMany(models.Article, { + through: 'Bookmark', + as: 'bookmarks', + foreignKey: 'userId', + otherKey: 'articleId' + }); }; return User; }; diff --git a/src/helpers/utilities.helper.js b/src/helpers/utilities.helper.js index 044a383..d4fe5bf 100644 --- a/src/helpers/utilities.helper.js +++ b/src/helpers/utilities.helper.js @@ -7,7 +7,7 @@ export default { */ async isNumeric(value) { - return !!Number(value); + return !!Number(value) && value > 0; }, /** diff --git a/src/middlewares/bookmarkCheck.middleware.js b/src/middlewares/bookmarkCheck.middleware.js new file mode 100644 index 0000000..1474769 --- /dev/null +++ b/src/middlewares/bookmarkCheck.middleware.js @@ -0,0 +1,31 @@ +import { getArticleInstance } from '../services/bookmark.service'; +import Utilities from '../helpers/utilities.helper'; +import Helper from '../services/helper'; + +export default { + /** + * @method bookmarkCheck + * Validates incoming bookmark requests + * @param {object} request + * @param {object} response + * @param {function} next + * @returns {object|function} error object response or next middleware function + */ + + async bookmarkCheck(request, response, next) { + const validSlug = await Utilities.isValidSlug(request.params.slug); + if (!validSlug) { + return Helper.failResponse(response, 400, { + message: 'Article slug is not valid' + }); + } + const article = await getArticleInstance(request.params.slug); + if (!article) { + return Helper.failResponse(response, 404, { + message: `Article does not exist` + }); + } + request.articleId = article.id; + next(); + } +}; diff --git a/src/routes/v1/article.route.js b/src/routes/v1/article.route.js index a54f1fe..5b01ce7 100644 --- a/src/routes/v1/article.route.js +++ b/src/routes/v1/article.route.js @@ -1,7 +1,9 @@ import express from 'express'; import commentController from '../../controllers/comment.controller'; +import bookmarkController from '../../controllers/bookmark.controller'; import authorization from '../../middlewares/auth.middleware'; import commentsCheck from '../../middlewares/commentsCheck.middleware'; +import bookmarksCheck from '../../middlewares/bookmarkCheck.middleware'; const router = express.Router(); const { @@ -11,8 +13,15 @@ const { getSingleComment } = commentController; +const { + createBookmark, + getUserBookmarks, + removeUserBookmark +} = bookmarkController; + const { verifyToken } = authorization; const { editCommentCheck, getArticlesCommentsCheck } = commentsCheck; +const { bookmarkCheck } = bookmarksCheck; router.get( '/:slug/comments', @@ -39,4 +48,14 @@ router.patch( editComment ); +router.post('/:slug/bookmarks', verifyToken, bookmarkCheck, createBookmark); + +router.get('/bookmarks', verifyToken, getUserBookmarks); +router.delete( + '/:slug/bookmarks', + verifyToken, + bookmarkCheck, + removeUserBookmark +); + export default router; diff --git a/src/services/bookmark.service.js b/src/services/bookmark.service.js new file mode 100644 index 0000000..b87e858 --- /dev/null +++ b/src/services/bookmark.service.js @@ -0,0 +1,114 @@ +import model from '../db/models'; + +const { Article, Bookmark, User } = model; +/** + * @method createBookmark + * Interacts with the database to add an article to a user's bookmarks + * @param {number} articleId ID of the article to be bookmarked + * @param {number} userId ID of the user making the bookmark request + * @returns {object|boolean} Bookmark instance object or boolean if bookmarks exists + */ + +export const createBookmark = async (articleId, userId) => { + const bookmarkExists = await Bookmark.findOne({ + where: { articleId, userId, isDeleted: false } + }); + if (bookmarkExists) return false; + + const bookmarked = await Bookmark.create({ + articleId, + userId + }); + return bookmarked; +}; + +/** + * @method getBookmarks + * Interacts with the database to fetch all bookmarks for a user + * @param {number} userId ID of the user making the bookmark request + * @returns {object} Bookmarks response object + */ + +export const getBookmarks = async userId => { + const userBookmarks = await User.findOne({ + where: { id: userId }, + attributes: { + exclude: [ + 'id', + 'email', + 'password', + 'bio', + 'image', + 'twitterHandle', + 'facebookHandle', + 'confirmEmail', + 'confirmEmailCode', + 'isNotified', + 'isPublished', + 'passwordToken', + 'socialAuth', + 'roleType', + 'createdAt', + 'updatedAt' + ] + }, + include: [ + { + model: Article, + as: 'bookmarks', + attributes: [ + 'title', + 'body', + 'image', + 'likesCount', + 'viewsCount', + 'description' + ], + through: { + model: Bookmark, + as: 'bookmarks', + where: { isDeleted: false }, + attributes: { + exclude: ['userId', 'articleId', 'isDeleted'] + } + } + } + ] + }); + + if (userBookmarks.bookmarks.length < 1) { + return { message: `You currently don't have any bookmarks` }; + } + return userBookmarks; +}; + +/** + * @method removeBookmark + * Interacts with the database to remove an article from a user's bookmarks + * @param {number} articleId + * @param {number} userId + * @returns {boolean} true if article was removed from bookmarks, false if otherwise + */ + +export const removeBookmark = async (articleId, userId) => { + const bookmark = await Bookmark.findOne({ + where: { articleId, userId, isDeleted: false } + }); + if (!bookmark) return false; + await bookmark.update({ isDeleted: true }); + return true; +}; + +/** + * @method getArticleInstance + * Queries the database to get an article instance + * @param {string} slug + * @returns {object} article instance object + */ + +export const getArticleInstance = async slug => { + const articleInstance = await Article.findOne({ + where: { slug } + }); + return articleInstance; +}; diff --git a/src/tests/controller/auth.spec.js b/src/tests/controller/auth.spec.js index 3c50375..d534494 100644 --- a/src/tests/controller/auth.spec.js +++ b/src/tests/controller/auth.spec.js @@ -430,7 +430,6 @@ describe('Auth API endpoints', () => { './src/tests/testFiles/default_avatar.png', 'image.jpeg' ); - console.log(response.body); expect(response).to.have.status(200); expect(response.body.data).to.have.keys( 'bio', diff --git a/src/tests/controller/bookmark.spec.js b/src/tests/controller/bookmark.spec.js new file mode 100644 index 0000000..7c420ad --- /dev/null +++ b/src/tests/controller/bookmark.spec.js @@ -0,0 +1,219 @@ +import chai from 'chai'; +import chaiHttp from 'chai-http'; +import sinon from 'sinon'; +import moment from 'moment'; +import sinonChai from 'sinon-chai'; +import dotenv from 'dotenv'; +import bookmarkController from '../../controllers/bookmark.controller'; +import app from '../../index'; +import models from '../../db/models'; +import { getUser, createUser } from '../utils/db.utils'; + +dotenv.config(); + +const { Article, Comment, User } = models; +const { + getUserBookmarks, + removeUserBookmark, + createBookmark +} = bookmarkController; +chai.use(chaiHttp); +chai.use(sinonChai); + +const { expect } = chai; + +let firstNewUserToken; +let secondNewUserToken; +let firstnewUser; + +before(async () => { + const user = getUser(); + firstnewUser = await createUser(user); + const user2 = getUser(); + await createUser(user2); + const response = await chai + .request(app) + .post(`${process.env.API_VERSION}/users/login`) + .send(user); + firstNewUserToken = response.body.data.token; + + const response2 = await chai + .request(app) + .post(`${process.env.API_VERSION}/users/login`) + .send(user2); + secondNewUserToken = response2.body.data.token; +}); + +before(async () => { + await Article.create({ + userId: firstnewUser.id, + title: 'first article title', + description: 'This is a description', + body: 'lorem ipsum whatever', + image: process.env.DEFAULT_IMAGE_URL, + createdAt: moment().format(), + updatedAt: moment().format() + }); + + await Article.create({ + userId: firstnewUser.id, + title: 'second article title', + description: 'This is a description', + body: 'lorem ipsum whatever', + image: process.env.DEFAULT_IMAGE_URL, + createdAt: moment().format(), + updatedAt: moment().format() + }); +}); + +describe('Bookmarks API Endpoints', () => { + after(async () => { + await Comment.destroy({ where: {}, cascade: true }); + await Article.destroy({ where: {}, cascade: true }); + await User.destroy({ where: {}, cascade: true }); + }); + describe('/POST /api/v1/articles/:slug/bookmarks', () => { + it(`Should add an article to a user's bookmark`, async () => { + const response = await chai + .request(app) + .post( + `${process.env.API_VERSION}/articles/first-article-title/bookmarks` + ) + .set('Authorization', `Bearer ${secondNewUserToken}`); + expect(response).to.have.status(201); + expect(response.body.status).to.be.equal('success'); + expect(response.body.data).to.have.key('message'); + expect(response.body.data.message).to.be.equal( + `Article added to bookmarks` + ); + }); + + it(`Should return an error if a user tries to add a previously bookmarked article to their bookmarks`, async () => { + const response = await chai + .request(app) + .post( + `${process.env.API_VERSION}/articles/first-article-title/bookmarks` + ) + .set('Authorization', `Bearer ${secondNewUserToken}`); + expect(response).to.have.status(409); + expect(response.body.status).to.be.equal('fail'); + expect(response.body.data).to.have.key('message'); + expect(response.body.data.message).to.be.equal( + `Article is already in your bookmarks` + ); + }); + + it('Should return an error when a user tries to bookmark a non existent article', async () => { + const response = await chai + .request(app) + .post( + `${process.env.API_VERSION}/articles/non-existent-article-title/bookmarks` + ) + .set('Authorization', `Bearer ${secondNewUserToken}`); + expect(response).to.have.status(404); + expect(response.body.status).to.be.equal('fail'); + expect(response.body.data).to.have.key('message'); + expect(response.body.data.message).to.be.equal(`Article does not exist`); + }); + + it('Should return an error when a user tries to bookmark an article with an invalid slug', async () => { + const response = await chai + .request(app) + .post( + `${process.env.API_VERSION}/articles/first-article-slug-/bookmarks` + ) + .set('Authorization', `Bearer ${secondNewUserToken}`); + expect(response).to.have.status(400); + expect(response.body.status).to.be.equal('fail'); + expect(response.body.data).to.have.key('message'); + expect(response.body.data.message).to.be.equal( + `Article slug is not valid` + ); + }); + it('should stub an unhandled error in the create user bookmarks method', async () => { + const nextCallback = sinon.spy(); + createBookmark({}, {}, nextCallback); + sinon.assert.calledOnce(nextCallback); + }); + }); + + describe('/GET /api/v1/articles/bookmarks', () => { + it(`Sholud fetch all bookmarks for a user`, async () => { + const response = await chai + .request(app) + .get(`${process.env.API_VERSION}/articles/bookmarks`) + .set('Authorization', `Bearer ${secondNewUserToken}`); + expect(response).to.have.status(200); + expect(response.body.status).to.be.equal('success'); + expect(response.body.data).to.have.keys( + 'firstName', + 'lastName', + 'userName', + 'bookmarks' + ); + expect(response.body.data.bookmarks[0]).to.have.keys( + 'title', + 'body', + 'image', + 'likesCount', + 'viewsCount', + 'description', + 'bookmarks' + ); + }); + + it('Should return a message if a user has no bookmarks', async () => { + const response = await chai + .request(app) + .get(`${process.env.API_VERSION}/articles/bookmarks`) + .set('Authorization', `Bearer ${firstNewUserToken}`); + expect(response).to.have.status(200); + expect(response.body.status).to.be.equal('success'); + expect(response.body.data).to.have.key('message'); + expect(response.body.data.message).to.be.equal( + `You currently don't have any bookmarks` + ); + }); + it('should stub an unhandled error in the get all user bookmarks method', async () => { + const nextCallback = sinon.spy(); + getUserBookmarks({}, {}, nextCallback); + sinon.assert.calledOnce(nextCallback); + }); + }); + + describe('/DELETE /api/v1/articles/:slug/bookmarks', () => { + it(`Should remove an article from a user's bookmarks`, async () => { + const response = await chai + .request(app) + .delete( + `${process.env.API_VERSION}/articles/first-article-title/bookmarks` + ) + .set('Authorization', `Bearer ${secondNewUserToken}`); + expect(response).to.have.status(200); + expect(response.body.status).to.be.equal('success'); + expect(response.body.data).to.have.key('message'); + expect(response.body.data.message).to.be.equal( + `Article has been removed from your bookmarks` + ); + }); + it(`Should return an error when a user tries to remove an already removed article from their bookmarks`, async () => { + const response = await chai + .request(app) + .delete( + `${process.env.API_VERSION}/articles/first-article-title/bookmarks` + ) + .set('Authorization', `Bearer ${secondNewUserToken}`); + expect(response).to.have.status(404); + expect(response.body.status).to.be.equal('fail'); + expect(response.body.data).to.have.key('message'); + expect(response.body.data.message).to.be.equal( + `Article is not present in your bookmarks` + ); + }); + it('should stub an unhandled error in the remove user bookmarks method', async () => { + const nextCallback = sinon.spy(); + removeUserBookmark({}, {}, nextCallback); + sinon.assert.calledOnce(nextCallback); + }); + }); +}); diff --git a/src/tests/controller/comment.spec.js b/src/tests/controller/comment.spec.js index f96b8bc..4bbd3d0 100644 --- a/src/tests/controller/comment.spec.js +++ b/src/tests/controller/comment.spec.js @@ -11,7 +11,7 @@ import { getUser, createUser } from '../utils/db.utils'; dotenv.config(); -const { Article, Comment, User } = models; +const { Article, Comment } = models; const { getArticleComments, getCommentHistory, @@ -109,12 +109,6 @@ before(async () => { }); describe('Comments API Endpoints', () => { - after(async () => { - await Comment.destroy({ where: {}, cascade: true }); - await Article.destroy({ where: {}, cascade: true }); - await User.destroy({ where: {}, cascade: true }); - }); - describe('/GET /api/v1/articles/:slug/comments/:id', () => { it('Should get single comment record', async () => { const response = await chai diff --git a/src/tests/controller/user.spec.js b/src/tests/controller/user.spec.js index 0b2cb26..a543f21 100644 --- a/src/tests/controller/user.spec.js +++ b/src/tests/controller/user.spec.js @@ -327,31 +327,31 @@ describe('User API endpoints', () => { }); describe('GET /users/follow/:userId', () => { - // it('Should follow another user', async () => { - // const response = await chai - // .request(app) - // .post('/api/v1/users/follow') - // .set('Authorization', `Bearer ${authorToken}`) - // .send({ - // userId: 19 - // }); - // expect(response).to.have.status(200); - // expect(response).to.be.an('object'); - // expect(response.body.data).to.equal('You have followed this user'); - // }); - - // it('Should follow another user', async () => { - // const response = await chai - // .request(app) - // .post('/api/v1/users/follow') - // .set('Authorization', `Bearer ${authorToken}`) - // .send({ - // userId: 24 - // }); - // expect(response).to.have.status(200); - // expect(response).to.be.an('object'); - // expect(response.body.data).to.equal('You have followed this user'); - // }); + it('Should follow another user', async () => { + const response = await chai + .request(app) + .post('/api/v1/users/follow') + .set('Authorization', `Bearer ${authorToken}`) + .send({ + userId: 23 + }); + expect(response).to.have.status(200); + expect(response).to.be.an('object'); + expect(response.body.data).to.equal('You have followed this user'); + }); + + it('Should follow another user', async () => { + const response = await chai + .request(app) + .post('/api/v1/users/follow') + .set('Authorization', `Bearer ${authorToken}`) + .send({ + userId: 24 + }); + expect(response).to.have.status(200); + expect(response).to.be.an('object'); + expect(response.body.data).to.equal('You have followed this user'); + }); it('Should follow another user', async () => { const response = await chai @@ -359,7 +359,7 @@ describe('User API endpoints', () => { .post('/api/v1/users/follow') .set('Authorization', `Bearer ${authorToken}`) .send({ - userId: 19 + userId: 22 }); expect(response).to.have.status(200); expect(response).to.be.an('object'); @@ -389,7 +389,7 @@ describe('User API endpoints', () => { it('Should return user has no follower', async () => { const response = await chai .request(app) - .get('/api/v1/users/follow/20') + .get('/api/v1/users/follow/22') .set('Authorization', `Bearer ${authorToken}`); expect(response).to.have.status(200); expect(response).to.be.an('object'); diff --git a/src/tests/index.spec.js b/src/tests/index.spec.js index e151973..49e161a 100644 --- a/src/tests/index.spec.js +++ b/src/tests/index.spec.js @@ -3,6 +3,7 @@ import './utils/db.utils'; import './misc/index.spec'; import './controller/comment.spec'; +import './controller/bookmark.spec'; import './middleware/auth.spec'; import './models/user.spec'; import './controller/auth.spec';