From 10d086bea36942f17a209f1fe8fb124ae5ecba9e Mon Sep 17 00:00:00 2001 From: Jacinta Date: Wed, 20 Mar 2019 23:08:15 +0100 Subject: [PATCH] (ft-article-reaction): add like or dislike article users should be able to like or dislike an article [Finishes #164139689] --- controllers/articles.js | 117 ++++++++++++++++++- middleware/validation.js | 26 +++++ migrations/20190319124843-create-reaction.js | 39 +++++++ models/article.js | 3 + models/reaction.js | 31 +++++ routes/v1.js | 29 ++++- test/article.spec.js | 94 ++++++++++++++- test/helpers/articleDummyData.js | 9 ++ 8 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 migrations/20190319124843-create-reaction.js create mode 100644 models/reaction.js diff --git a/controllers/articles.js b/controllers/articles.js index 106d365..3b5e258 100644 --- a/controllers/articles.js +++ b/controllers/articles.js @@ -1,4 +1,4 @@ -import { Article } from '../models'; +import { Article, Reaction } from '../models'; /** * @class ArticleController @@ -102,4 +102,119 @@ export default class ArticleController { }); } } + + /** + * @desc check if a user likes an article + * @param {Object} req - the request object + * @param {Object} res - the response object + * @memberof ArticleController + * @return {Object} returns an object + */ + static async likeArticle(req, res) { + const { id: userId } = req.user; + const { slug: articleSlug } = req.params; + + try { + // const likes = await Reaction.findAndCountAll({ + // where: { + // like: true + // }, + // include: [{ + // model: User, + // as: 'user', + // attributes: ['username', 'bio', 'name'] + // }, { + // model: Article, + // as: 'article', + // attributes: ['articleSlug'] + // }] + // }); + const likeArticle = await Reaction.findOne({ + where: { + articleSlug, + userId + } + }); + if (!likeArticle) { + await Reaction.create({ + articleSlug, + userId, + like: true, + }); + return res.status(201).json({ + success: true, + message: 'Article liked successfully', + // like: likes.count + }); + } if ( + (likeArticle) + && (likeArticle.like === true)) { + await Reaction.destroy({ + where: { + articleSlug, + userId, + } + }); + return res.status(200).json({ + success: true, + message: 'You have unliked this article' + }); + } + } catch (error) { + return res.status(500).json({ + success: false, + errors: [error.message] + }); + } + } + + /** + * @desc check if a user dislikes an article + * @param {Object} req - the request object + * @param {Object} res - the response object + * @memberof ArticleController + * @return {Object} returns an object + */ + static async dislikeArticle(req, res) { + const { id: userId } = req.user; + const { slug: articleSlug } = req.params; + + try { + const likeArticle = await Reaction.findOne({ + where: { + articleSlug, + userId + } + }); + if (!likeArticle) { + await Reaction.create({ + articleSlug, + userId, + like: false + }); + return res.status(201).json({ + success: true, + message: 'Article disliked successfully' + }); + } if ( + (likeArticle) + && (likeArticle.like === false)) { + await Reaction.destroy({ + where: { + articleSlug, + userId, + } + }); + return res.status(200).json({ + success: true, + message: 'You have removed the dislike on this article' + }); + } + } catch (error) { + return res.status(500).json({ + success: false, + errors: [error.message] + }); + } + } } diff --git a/middleware/validation.js b/middleware/validation.js index 057abdb..dadea03 100644 --- a/middleware/validation.js +++ b/middleware/validation.js @@ -116,6 +116,7 @@ export const validateArticleAuthor = async (req, res, next) => { }); } }; + export const validateCategory = [ check('category') .exists() @@ -141,3 +142,28 @@ export const validatePassword = [ .custom(value => !/\s/.test(value)) .withMessage('No spaces are allowed in the password.') ]; + +export const checkIfArticleExists = async (req, res, next) => { + const { + params: { slug } + } = req; + try { + const article = await Article.findOne({ + where: { + slug + } + }); + if (!article) { + return res.status(404).json({ + success: false, + errors: ['Article not found'] + }); + } + return next(); + } catch (error) { + return res.status(500).json({ + success: false, + errors: ['Article does not exist'] + }); + } +}; diff --git a/migrations/20190319124843-create-reaction.js b/migrations/20190319124843-create-reaction.js new file mode 100644 index 0000000..29d96f2 --- /dev/null +++ b/migrations/20190319124843-create-reaction.js @@ -0,0 +1,39 @@ +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Reactions', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Users', + key: 'id', + as: 'User' + } + }, + articleSlug: { + type: Sequelize.STRING, + allowNull: false, + unique: { + args: true, + msg: 'Article should have a unique slug' + } + }, + like: { + type: Sequelize.BOOLEAN + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Reactions') +}; diff --git a/models/article.js b/models/article.js index f5bc16d..684acc7 100644 --- a/models/article.js +++ b/models/article.js @@ -61,6 +61,9 @@ module.exports = (sequelize, DataTypes) => { Article.belongsTo(models.Category, { foreignKey: 'categoryId', }); + Article.hasMany(models.Reaction, { + foreignKey: 'articleSlug', + }); }; return Article; }; diff --git a/models/reaction.js b/models/reaction.js new file mode 100644 index 0000000..c8fb1c7 --- /dev/null +++ b/models/reaction.js @@ -0,0 +1,31 @@ +module.exports = (sequelize, DataTypes) => { + const Reaction = sequelize.define('Reaction', { + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + articleSlug: { + type: DataTypes.STRING, + allowNull: false, + unique: { + args: true, + msg: 'Article should have a unique slug' + } + }, + like: { + type: DataTypes.BOOLEAN + } + }, {}); + Reaction.associate = (models) => { + Reaction.belongsTo(models.Article, { + foreignKey: 'articleSlug', + as: 'article', + onDelete: 'CASCADE' + }); + Reaction.belongsTo(models.User, { + foreignKey: 'userId', + as: 'user' + }); + }; + return Reaction; +}; diff --git a/routes/v1.js b/routes/v1.js index 6342dbd..4a787f0 100644 --- a/routes/v1.js +++ b/routes/v1.js @@ -17,10 +17,17 @@ import { validateArticle, validateArticleAuthor, validateCategory, - returnValidationErrors + returnValidationErrors, + checkIfArticleExists } from '../middleware/validation'; -const { createArticle, updateArticle, deleteArticle } = ArticleController; +const { + createArticle, + updateArticle, + deleteArticle, + likeArticle, + dislikeArticle +} = ArticleController; const apiRoutes = express.Router(); @@ -128,4 +135,22 @@ apiRoutes.post( UserController.resetPassword ); +apiRoutes.post( + '/likeArticle/:slug', + Auth.verifyUser, + isUserVerified, + checkIfArticleExists, + returnValidationErrors, + likeArticle +); + +apiRoutes.post( + '/dislikeArticle/:slug', + Auth.verifyUser, + isUserVerified, + checkIfArticleExists, + returnValidationErrors, + dislikeArticle +); + export default apiRoutes; diff --git a/test/article.spec.js b/test/article.spec.js index 99a8cd6..46d1255 100644 --- a/test/article.spec.js +++ b/test/article.spec.js @@ -6,7 +6,7 @@ import chaiHttp from 'chai-http'; import updateVerifiedStatus from './helpers/updateVerifiedStatus'; import app from '../index'; import { article1, user2 } from './helpers/dummyData'; -import { article2 } from './helpers/articleDummyData'; +import { article2, articleReaction } from './helpers/articleDummyData'; import { myUser } from './helpers/userDummyData'; chai.use(chaiHttp); @@ -16,6 +16,9 @@ let userToken; let userToken2; let token; let articleSlug; +let articleSlug2; +let articleSlug3; +let reaction; describe('ARTICLES', () => { before((done) => { @@ -99,6 +102,7 @@ describe('ARTICLES', () => { expect(errors[0]).to.be.equal('Article should have a title.'); expect(errors[1]).to.be.equal('Title should be at least 6 characters long.'); expect(errors[2]).to.be.equal('Article should have a description.'); + expect(errors[3]).to.be.equal('Description should be at least 6 characters long.'); done(err); }); }); @@ -274,3 +278,91 @@ describe('/DELETE articles slug', () => { }); }); }); + + +describe('/POST articles like', () => { + it('should create an article', (done) => { + chai + .request(app) + .post('/api/v1/articles') + .set('authorization', userToken) + .send(article2) + .end((err, res) => { + articleSlug2 = res.body.article.slug; + expect(res).to.have.status(201); + expect(res.body).to.have.property('success').equal(true); + expect(res.body).to.have.property('message').equal('New article created successfully'); + done(err); + }); + }); + + it('should return an error if the article is not found', (done) => { + chai + .request(app) + .post('/api/v1/likeArticle/article-writing-b4ngikh') + .set('authorization', userToken2) + .end((err, res) => { + expect(res).to.have.status(404); + expect(res.body).to.have.property('success').equal(false); + expect(res.body.errors[0]).to.be.equal('Article not found'); + done(err); + }); + }); + + it('should create an article like reaction', (done) => { + chai + .request(app) + .post(`/api/v1/likeArticle/${articleSlug2}`) + .set('authorization', userToken2) + .end((err, res) => { + reaction = res.body.reaction; + expect(res).to.have.status(201); + expect(res.body).to.have.property('success').equal(true); + expect(res.body).to.have.property('message').equal('Article liked successfully'); + done(err); + }); + }); + + it('should unlike an article reaction if the article has been liked', (done) => { + chai + .request(app) + .post(`/api/v1/likeArticle/${articleSlug2}`) + .set('authorization', userToken2) + .end((err, res) => { + expect(res).to.have.status(200); + expect(res.body).to.have.property('success').equal(true); + expect(res.body).to.have.property('message').equal('You have unliked this article'); + done(err); + }); + }); +}); + + +describe('/POST articles dislike', () => { + it('should create an article dislike reaction', (done) => { + chai + .request(app) + .post(`/api/v1/dislikeArticle/${articleSlug2}`) + .set('authorization', userToken2) + .end((err, res) => { + reaction = res.body.reaction; + expect(res).to.have.status(201); + expect(res.body).to.have.property('success').equal(true); + expect(res.body).to.have.property('message').equal('Article disliked successfully'); + done(err); + }); + }); + + it('should remove an article reaction if the article has been disliked', (done) => { + chai + .request(app) + .post(`/api/v1/dislikeArticle/${articleSlug2}`) + .set('authorization', userToken2) + .end((err, res) => { + expect(res).to.have.status(200); + expect(res.body).to.have.property('success').equal(true); + expect(res.body).to.have.property('message').equal('You have removed the dislike on this article'); + done(err); + }); + }); +}); diff --git a/test/helpers/articleDummyData.js b/test/helpers/articleDummyData.js index 06bf36f..7fda33e 100644 --- a/test/helpers/articleDummyData.js +++ b/test/helpers/articleDummyData.js @@ -10,4 +10,13 @@ export const article2 = { description: 'An article can be updated', body: 'Update an article if you are the owner of the article.' }; + +export const articleReaction = { + like: true +}; + +export const articleReaction2 = { + like: false +}; + export default article1;