Skip to content

Commit

Permalink
feature(article): article tags functionality
Browse files Browse the repository at this point in the history
modify create article controller
add tag functionality
write tests
[Finishes #166816113]
  • Loading branch information
devPinheiro committed Jul 24, 2019
1 parent f4d5b14 commit 31ad346
Show file tree
Hide file tree
Showing 13 changed files with 214 additions and 37 deletions.
2 changes: 1 addition & 1 deletion src/controllers/article.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default {

/**
* @method getUserPublishedArticles
* @description fetch all published articles
* @description fetch all published articles by a specific user
* Route: GET: /articles/publish
* @param {Object} request request object
* @param {Object} response request object
Expand Down
8 changes: 8 additions & 0 deletions src/db/migrations/20190708085450-create-article.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ module.exports = {
type: Sequelize.INTEGER,
defaultValue: 0
},
likesCount: {
type: Sequelize.INTEGER,
defaultValue: 0
},
viewsCount: {
type: Sequelize.INTEGER,
defaultValue: 0
},
isPublished: {
type: Sequelize.BOOLEAN,
allowNull: false,
Expand Down
26 changes: 26 additions & 0 deletions src/db/migrations/20190717164759-create-tag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Tags', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
name: {
type: Sequelize.STRING
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: queryInterface => {
return queryInterface.dropTable('Tags');
}
};
28 changes: 28 additions & 0 deletions src/db/migrations/20190718055256-associate-article-tag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module.exports = {
up: (queryInterface, Sequelize) => {
// Article belongsToMany Tag
return queryInterface.createTable('ArticleTags', {
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
},
ArticleId: {
type: Sequelize.INTEGER,
primaryKey: true
},
TagId: {
type: Sequelize.INTEGER,
primaryKey: true
}
});
},

down: queryInterface => {
// remove table
return queryInterface.dropTable('ArticleTags');
}
};
6 changes: 6 additions & 0 deletions src/db/models/article.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default (sequelize, DataTypes) => {
as: 'author',
onDelete: 'CASCADE'
});

Article.hasMany(
models.Rating,
{
Expand All @@ -112,6 +113,11 @@ export default (sequelize, DataTypes) => {
foreignKey: 'articleId',
otherKey: 'userId'
});
// defines many-to-many association with the tags table
Article.belongsToMany(models.Tag, {
as: 'Tags',
through: 'ArticleTags'
});
};
return Article;
};
22 changes: 22 additions & 0 deletions src/db/models/tag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module.exports = (sequelize, DataTypes) => {
const Tag = sequelize.define(
'Tag',
{
name: {
type: DataTypes.STRING,
allowNull: {
args: true
}
}
},
{}
);
Tag.associate = models => {
// defines association between articles and tags table
Tag.belongsToMany(models.Article, {
as: 'Tagged',
through: 'ArticleTags'
});
};
return Tag;
};
2 changes: 1 addition & 1 deletion src/middlewares/auth.middleware.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import model from '../db/models';
import { isTokenInBlackListService } from '../services/auth.service';
import model from '../db/models';

const { Article, User } = model;

Expand Down
68 changes: 66 additions & 2 deletions src/services/article.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const {
Follow,
Rating,
ArticleReaction,
CommentReaction
CommentReaction,
Tag
} = model;
/** Istanbul ignore next */
/**
Expand All @@ -26,15 +27,45 @@ const {
*/
// eslint-disable-next-line import/prefer-default-export
export const createArticleService = async data => {
const { title, body, description } = data.body;
const { title, body, description, tag } = data.body;
const userId = data.user.id;
const readTime = await readtime(body);
const articleTags = tag
? tag
.toLowerCase()
.trim()
.split(', ')
: null;
const tagList = [];

if (articleTags) {
articleTags.forEach(async tagItem => {
// check if tag exists in the tags table
const tagExists = await Tag.findOne({
where: {
name: tagItem
}
});
// tag exists
if (tagExists) {
tagList.push(tagExists.id);
}
// if tag does not exist, create new tag and push to tagList
if (!tagExists) {
const tagResult = await Tag.create({
name: tagItem
});
tagList.push(tagResult.id);
}
});
}

const uploadedImage = [];
const images = data.files;
const imagePaths = [];

const loopUpload = async image => {
// for each image in the request files
// eslint-disable-next-line no-restricted-syntax
for (const imageItem of image) {
const imagePath = imageItem.path;
Expand All @@ -61,6 +92,9 @@ export const createArticleService = async data => {
image: finalUploads,
readTime
});
// set
await article.setTags(tagList);
article.setDataValue('tagList', articleTags);
return article;
};

Expand All @@ -83,6 +117,12 @@ export const getArticleService = async data => {
model: User,
as: 'author',
attributes: ['firstName', 'lastName', 'image']
},
{
model: Tag,
as: 'Tags',
attributes: ['name'],
through: { attributes: [] }
}
]
});
Expand Down Expand Up @@ -133,6 +173,12 @@ export const userGetDraftArticleService = async data => {
model: User,
as: 'author',
attributes: ['firstName', 'lastName', 'image']
},
{
model: Tag,
as: 'Tags',
attributes: ['name'],
through: { attributes: [] }
}
]
});
Expand Down Expand Up @@ -160,6 +206,12 @@ export const userGetPublishedArticleService = async data => {
model: User,
as: 'author',
attributes: ['firstName', 'lastName', 'image']
},
{
model: Tag,
as: 'Tags',
attributes: ['name'],
through: { attributes: [] }
}
]
});
Expand Down Expand Up @@ -187,6 +239,12 @@ export const getSingleUserPublishedArticleService = async data => {
model: User,
as: 'author',
attributes: ['firstName', 'lastName', 'image']
},
{
model: Tag,
as: 'Tags',
attributes: ['name'],
through: { attributes: [] }
}
]
});
Expand Down Expand Up @@ -222,6 +280,12 @@ export const getAllPublishedArticleService = async (
model: User,
as: 'author',
attributes: ['firstName', 'lastName', 'image']
},
{
model: Tag,
as: 'Tags',
attributes: ['name'],
through: { attributes: [] }
}
]
});
Expand Down
49 changes: 48 additions & 1 deletion src/tests/controller/article.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,36 @@ describe('Article API endpoints', () => {
done();
});
});
it('Should successfully create an article with tags', done => {
chai
.request(app)
.post(`${process.env.API_VERSION}/articles`)
.set({ Authorization: `Bearer ${userToken}` })
.field('title', 'first article')
.field('description', 'this is a description')
.field('body', 'this is a description this is a description')
.field('tag', 'javascript, php, java')
.end((error, response) => {
expect(response.status).to.equal(201);
expect(response).to.be.an('Object');
expect(response.body).to.have.property('status');
expect(response.body).to.have.property('data');
expect(response.body.status).to.equal('success');
expect(response.body.data.title).to.equal('first article');
expect(response.body.data.description).to.equal(
'this is a description'
);
expect(response.body.data.body).to.equal(
'this is a description this is a description'
);
expect(response.body.data.tagList[0]).to.equal('javascript');
expect(response.body.data.tagList[1]).to.equal('php');
expect(response.body.data.tagList[2]).to.equal('java');
done();
});
});

it('Should successfully create an article', done => {
it('Should return an error if request is empty', done => {
chai
.request(app)
.post(`${process.env.API_VERSION}/articles`)
Expand Down Expand Up @@ -448,6 +476,25 @@ describe('Article API endpoints', () => {
});
});

it('Should return an error if user is unauthorized', done => {
chai
.request(app)
.put(`${process.env.API_VERSION}/articles/publish/${thirdArticle.slug}`)
.set({ Authorization: `Bearer ${secondUserToken}` })
.send(getArticleData())
.end((error, response) => {
expect(response.status).to.equal(403);
expect(response).to.be.an('Object');
expect(response.body).to.have.property('status');
expect(response.body).to.have.property('data');
expect(response.body.status).to.equal('fail');
expect(response.body.data.message).to.equal(
'Forbidden, you can not publish this resource'
);
done();
});
});

it('Should return internal server error', async () => {
const request = {
body: {}
Expand Down
7 changes: 7 additions & 0 deletions src/tests/controller/auth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import app from '../../index';
import { getPasswordResetToken } from '../../helpers/jwt.helper';
import * as imageHelper from '../../helpers/image.helper';
import model from '../../db/models';
import * as mail from '../../helpers/mail.helper';

const { User } = model;

Expand All @@ -22,6 +23,7 @@ let userToken;
let secondUserToken;
let deletedUserToken;
let mockImage;
let mockMail;

const { expect } = chai;

Expand All @@ -42,7 +44,12 @@ describe('Auth API endpoints', () => {
done(err);
});
});
after(() => {
mockMail.restore();
});
describe('POST /users/signup', () => {
mockMail = sinon.stub(mail, 'sendWelcomeEmail').resolves({});
mockMail = sinon.stub(mail, 'sendForgotPasswordMail').resolves({});
before(done => {
chai
.request(app)
Expand Down
2 changes: 1 addition & 1 deletion src/tests/controller/user.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import chaiHttp from 'chai-http';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import dotenv from 'dotenv';

import { Response, createUser, getUser } from '../utils/db.utils';
import userController from '../../controllers/user.controller';
import app from '../../index';
Expand Down Expand Up @@ -53,6 +52,7 @@ describe('User API endpoints', () => {
});
});
});

describe('POST /users/follow/', () => {
it('Should log user in successfully to test follow feature', async () => {
const user = getUser();
Expand Down
Loading

0 comments on commit 31ad346

Please sign in to comment.