From 055dca8f9eacf0b0e175517e1e4c3d511b2fa671 Mon Sep 17 00:00:00 2001 From: Lundii Date: Tue, 6 Aug 2019 10:03:20 +0100 Subject: [PATCH] ft(backend): adds search functionality users can search by keyword uset can search by keyword and/or categories,tags,authornames [Delivers #167190452] --- package-lock.json | 5 + package.json | 2 + server/controllers/articleController.js | 18 +- .../20190803192315-create-categories.js | 29 ++++ .../migrations/20190803192334-create-tags.js | 29 ++++ .../20190803192656-create-article-tags.js | 27 +++ ...0190803193836-create-article-categories.js | 27 +++ server/db/models/article.js | 8 + server/db/models/articlecategories.js | 14 ++ server/db/models/articletags.js | 14 ++ server/db/models/categories.js | 17 ++ server/db/models/tags.js | 17 ++ server/db/models/user.js | 2 +- .../seeders/20190801215421-articles-seed.js | 28 +++ .../seeders/20190803194225-demo-categories.js | 20 +++ .../20190803194616-demo-articleCategories.js | 25 +++ server/db/seeders/20190805085916-tags.js | 19 +++ .../db/seeders/20190805085934-articleTags.js | 25 +++ server/docs/ah-commando-doc.yml | 44 ++++- server/helpers/articleSearch.js | 113 +++++++++++++ server/helpers/index.js | 6 +- server/helpers/paginate.js | 22 ++- server/middlewares/index.js | 6 +- server/middlewares/schema.js | 16 ++ server/middlewares/searchValidator.js | 37 ++++ server/routes/article.js | 11 +- server/tests/article.test.js | 160 ++++++++++++++++++ server/tests/comment.test.js | 2 +- 28 files changed, 726 insertions(+), 17 deletions(-) create mode 100644 server/db/migrations/20190803192315-create-categories.js create mode 100644 server/db/migrations/20190803192334-create-tags.js create mode 100644 server/db/migrations/20190803192656-create-article-tags.js create mode 100644 server/db/migrations/20190803193836-create-article-categories.js create mode 100644 server/db/models/articlecategories.js create mode 100644 server/db/models/articletags.js create mode 100644 server/db/models/categories.js create mode 100644 server/db/models/tags.js create mode 100644 server/db/seeders/20190801215421-articles-seed.js create mode 100644 server/db/seeders/20190803194225-demo-categories.js create mode 100644 server/db/seeders/20190803194616-demo-articleCategories.js create mode 100644 server/db/seeders/20190805085916-tags.js create mode 100644 server/db/seeders/20190805085934-articleTags.js create mode 100644 server/helpers/articleSearch.js create mode 100644 server/middlewares/searchValidator.js diff --git a/package-lock.json b/package-lock.json index 2c6878d..5cbb047 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3355,6 +3355,11 @@ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, + "faker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/faker/-/faker-4.1.0.tgz", + "integrity": "sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8=" + }, "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", diff --git a/package.json b/package.json index f37b2e9..0e3b191 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "dotenv": "^6.0.0", "errorhandler": "^1.5.0", "express": "^4.16.3", + "faker": "^4.1.0", "jsonwebtoken": "^8.3.0", + "lodash": "^4.17.15", "morgan": "^1.9.1", "multer": "^1.4.2", "node-cron": "^2.0.3", diff --git a/server/controllers/articleController.js b/server/controllers/articleController.js index 880bddb..1a78419 100644 --- a/server/controllers/articleController.js +++ b/server/controllers/articleController.js @@ -1,11 +1,14 @@ import uuid from 'uuid'; import sequelize from 'sequelize'; import models from '../db/models'; +import helpers from '../helpers'; import utils from '../helpers/Utilities'; + import Paginate from '../helpers/paginate'; const { Op } = sequelize; const { paginateArticles } = Paginate; +const { querySearch, filterSearch } = helpers; /** * @Module ArticleController * @description Controlls all activities related to Articles @@ -47,11 +50,20 @@ class ArticleController { * @memberof ArticleController */ static async getAllArticles(req, res) { + const { searchQuery } = req.query; + const queryFilters = req.body; + let articles; const { page, limit } = req.query; if (!page && !limit) { - const articles = await models.Article.findAll({ - include: [{ model: models.User, as: 'author', attributes: ['firstname', 'lastname', 'username', 'image', 'email'] }] - }); + if (!searchQuery) { + articles = await models.Article.findAll({ + include: [{ model: models.User, as: 'author', attributes: ['firstname', 'lastname', 'username', 'image', 'email'] }] + }); + } else if (searchQuery && Object.keys(queryFilters)[0] !== 'undefined') { + articles = await filterSearch(searchQuery, queryFilters); + } else { + articles = await querySearch(searchQuery); + } return utils.successStat(res, 200, 'articles', articles); } paginateArticles(req, res); diff --git a/server/db/migrations/20190803192315-create-categories.js b/server/db/migrations/20190803192315-create-categories.js new file mode 100644 index 0000000..1e51ea8 --- /dev/null +++ b/server/db/migrations/20190803192315-create-categories.js @@ -0,0 +1,29 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Categories', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + name: { + type: Sequelize.STRING, + unique: true, + allowNull: false + }, + description: { + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Categories') +}; diff --git a/server/db/migrations/20190803192334-create-tags.js b/server/db/migrations/20190803192334-create-tags.js new file mode 100644 index 0000000..45f376b --- /dev/null +++ b/server/db/migrations/20190803192334-create-tags.js @@ -0,0 +1,29 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Tags', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + name: { + type: Sequelize.STRING, + unique: true, + allowNull: false + }, + description: { + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Tags') +}; diff --git a/server/db/migrations/20190803192656-create-article-tags.js b/server/db/migrations/20190803192656-create-article-tags.js new file mode 100644 index 0000000..1bd7dac --- /dev/null +++ b/server/db/migrations/20190803192656-create-article-tags.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('ArticleTags', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + articleId: { + type: Sequelize.INTEGER + }, + tagId: { + type: Sequelize.INTEGER + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('ArticleTags') +}; diff --git a/server/db/migrations/20190803193836-create-article-categories.js b/server/db/migrations/20190803193836-create-article-categories.js new file mode 100644 index 0000000..a66b68e --- /dev/null +++ b/server/db/migrations/20190803193836-create-article-categories.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('ArticleCategories', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + articleId: { + type: Sequelize.INTEGER + }, + categoryId: { + type: Sequelize.INTEGER + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('ArticleCategories') +}; diff --git a/server/db/models/article.js b/server/db/models/article.js index 3a123e5..61f962a 100644 --- a/server/db/models/article.js +++ b/server/db/models/article.js @@ -32,6 +32,14 @@ module.exports = (sequelize, DataTypes) => { Article.hasMany(models.Comment, { foreignKey: 'articleId', onDelete: 'CASCADE', as: 'comment', hooks: true }); + Article.belongsToMany(models.Categories, { + through: 'ArticleCategories', + foreignKey: 'articleId' + }); + Article.belongsToMany(models.Tags, { + through: 'ArticleTags', + foreignKey: 'articleId' + }); }; return Article; }; diff --git a/server/db/models/articlecategories.js b/server/db/models/articlecategories.js new file mode 100644 index 0000000..03ce977 --- /dev/null +++ b/server/db/models/articlecategories.js @@ -0,0 +1,14 @@ +/* eslint-disable func-names */ + +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const ArticleCategories = sequelize.define('ArticleCategories', { + articleId: DataTypes.INTEGER, + categoryId: DataTypes.INTEGER + }, {}); + ArticleCategories.associate = function () { + // associations can be defined here + }; + return ArticleCategories; +}; diff --git a/server/db/models/articletags.js b/server/db/models/articletags.js new file mode 100644 index 0000000..a1a9550 --- /dev/null +++ b/server/db/models/articletags.js @@ -0,0 +1,14 @@ +/* eslint-disable func-names */ + +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const ArticleTags = sequelize.define('ArticleTags', { + articleId: DataTypes.INTEGER, + tagId: DataTypes.INTEGER + }, {}); + ArticleTags.associate = function () { + // associations can be defined here + }; + return ArticleTags; +}; diff --git a/server/db/models/categories.js b/server/db/models/categories.js new file mode 100644 index 0000000..4015b4a --- /dev/null +++ b/server/db/models/categories.js @@ -0,0 +1,17 @@ +/* eslint-disable func-names */ + +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Categories = sequelize.define('Categories', { + name: DataTypes.STRING, + description: DataTypes.STRING + }, {}); + Categories.associate = function (models) { + Categories.belongsToMany(models.Article, { + through: 'ArticleCategories', + foreignKey: 'categoryId' + }); + }; + return Categories; +}; diff --git a/server/db/models/tags.js b/server/db/models/tags.js new file mode 100644 index 0000000..a5e79ad --- /dev/null +++ b/server/db/models/tags.js @@ -0,0 +1,17 @@ +/* eslint-disable func-names */ + +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Tags = sequelize.define('Tags', { + name: DataTypes.STRING, + description: DataTypes.STRING + }, {}); + Tags.associate = function (models) { + Tags.belongsToMany(models.Article, { + through: 'ArticleTags', + foreignKey: 'tagId' + }); + }; + return Tags; +}; diff --git a/server/db/models/user.js b/server/db/models/user.js index 7c6c9d1..54fb551 100644 --- a/server/db/models/user.js +++ b/server/db/models/user.js @@ -29,7 +29,7 @@ module.exports = (sequelize, DataTypes) => { hooks: true, timestamps: false, }); - User.hasMany(models.Article, { as: 'authorId', foreignKey: 'authorId', onDelete: 'CASCADE' }); + User.hasMany(models.Article, { foreignKey: 'id', onDelete: 'CASCADE' }); User.hasMany(models.Comment, { foreignKey: 'authorId', onDelete: 'CASCADE' }); }; return User; diff --git a/server/db/seeders/20190801215421-articles-seed.js b/server/db/seeders/20190801215421-articles-seed.js new file mode 100644 index 0000000..fb134c6 --- /dev/null +++ b/server/db/seeders/20190801215421-articles-seed.js @@ -0,0 +1,28 @@ +'use strict'; + +const _ = require('lodash'); +const faker = require('faker'); + +module.exports = { + up: (queryInterface) => { + const randomArticles = _.times(30, () => ({ + title: faker.name.title(), + description: faker.lorem.sentence(), + articleBody: faker.lorem.sentence(), + uuid: faker.random.number({ max: '300' }), + slug: faker.lorem.words(), + image: faker.lorem.sentence(), + authorId: faker.random.number({ + min: 1, + max: 12 + }), + favorited: true, + favoriteCounts: 25, + createdAt: new Date(), + updatedAt: new Date() + })); + return queryInterface.bulkInsert('Articles', randomArticles, {}); + }, + + down: queryInterface => queryInterface.bulkDelete('Articles', null, {}) +}; diff --git a/server/db/seeders/20190803194225-demo-categories.js b/server/db/seeders/20190803194225-demo-categories.js new file mode 100644 index 0000000..a8925ba --- /dev/null +++ b/server/db/seeders/20190803194225-demo-categories.js @@ -0,0 +1,20 @@ +'use strict'; + +const faker = require('faker'); + +const categories = ['technology', 'health', 'science', 'fashion', 'education', 'culture', 'lifestyle']; + +module.exports = { + up: (queryInterface) => { + const randomCategories = categories.map(item => ({ + name: item, + description: faker.lorem.sentence(), + createdAt: new Date(), + updatedAt: new Date() + })); + + return queryInterface.bulkInsert('Categories', randomCategories, {}); + }, + + down: queryInterface => queryInterface.bulkDelete('Categories', null, {}) +}; diff --git a/server/db/seeders/20190803194616-demo-articleCategories.js b/server/db/seeders/20190803194616-demo-articleCategories.js new file mode 100644 index 0000000..251b06d --- /dev/null +++ b/server/db/seeders/20190803194616-demo-articleCategories.js @@ -0,0 +1,25 @@ +'use strict'; + +const _ = require('lodash'); +const faker = require('faker'); + +module.exports = { + up: (queryInterface) => { + const articleCategories = _.times(30, () => ({ + articleId: faker.random.number({ + min: 1, + max: 30 + }), + categoryId: faker.random.number({ + min: 1, + max: 7 + }), + createdAt: new Date(), + updatedAt: new Date() + })); + + return queryInterface.bulkInsert('ArticleCategories', articleCategories, {}); + }, + + down: queryInterface => queryInterface.bulkDelete('ArticleCategories', null, {}) +}; diff --git a/server/db/seeders/20190805085916-tags.js b/server/db/seeders/20190805085916-tags.js new file mode 100644 index 0000000..4c36e21 --- /dev/null +++ b/server/db/seeders/20190805085916-tags.js @@ -0,0 +1,19 @@ +'use strict'; + +const faker = require('faker'); + +const tags = ['javascript', 'AI', 'travel', 'peace', 'believe', 'race', 'react', 'tutorial', 'knowledge']; +module.exports = { + up: (queryInterface) => { + const randomCategories = tags.map(item => ({ + name: item, + description: faker.lorem.sentence(), + createdAt: new Date(), + updatedAt: new Date() + })); + + return queryInterface.bulkInsert('Tags', randomCategories, {}); + }, + + down: queryInterface => queryInterface.bulkDelete('Tags', null, {}) +}; diff --git a/server/db/seeders/20190805085934-articleTags.js b/server/db/seeders/20190805085934-articleTags.js new file mode 100644 index 0000000..9dc9c30 --- /dev/null +++ b/server/db/seeders/20190805085934-articleTags.js @@ -0,0 +1,25 @@ +'use strict'; + +const _ = require('lodash'); +const faker = require('faker'); + +module.exports = { + up: (queryInterface) => { + const articleCategories = _.times(30, () => ({ + articleId: faker.random.number({ + min: 1, + max: 30 + }), + tagId: faker.random.number({ + min: 1, + max: 9 + }), + createdAt: new Date(), + updatedAt: new Date() + })); + + return queryInterface.bulkInsert('ArticleTags', articleCategories, {}); + }, + + down: queryInterface => queryInterface.bulkDelete('ArticleTags', null, {}) +}; diff --git a/server/docs/ah-commando-doc.yml b/server/docs/ah-commando-doc.yml index e92da69..8f5ff9d 100644 --- a/server/docs/ah-commando-doc.yml +++ b/server/docs/ah-commando-doc.yml @@ -418,13 +418,51 @@ paths: get: tags: - Articles - summary: get all articles + summary: get all articles. Limit to searchQuery if it is supplied description: user can get all articles + parameters: + - in: query + name: searchQuery + schema: + type: string responses: '200': description: successfully view all articles - '401': - description: Unauthorized + '500': + description: Server error + content: + application/json: + schema: + $ref: "#/components/schemas/errorResponse" + /articles/search/filter: + post: + tags: + - Articles + summary: get all articles marching the search query and filters supplied + description: users can get a single article + parameters: + - in: query + name: searchQuery + schema: + type: string + required: true + requestBody: + content: + application/json: + schema: + type: object + properties: + categories: + type: string + authorNqmes: + type: string + tags: + type: string + responses: + '200': + description: successfully viewed an articles + '400': + description: validationError content: application/json: schema: diff --git a/server/helpers/articleSearch.js b/server/helpers/articleSearch.js new file mode 100644 index 0000000..89eb90f --- /dev/null +++ b/server/helpers/articleSearch.js @@ -0,0 +1,113 @@ +import { Op } from 'sequelize'; +import models from '../db/models'; + +const { + Article, User, Categories, Tags +} = models; +const searchQuery = keyWord => ({ + '$Article.title$': { [Op.iLike]: `%${keyWord}%` }, + '$Article.description$': { [Op.iLike]: `%${keyWord}%` }, + '$author.username$': { [Op.iLike]: `%${keyWord}%` }, + '$author.firstname$': { [Op.iLike]: `%${keyWord}%` }, + '$author.lastname$': { [Op.iLike]: `%${keyWord}%` } + +}); + +const getSearchFilters = (categoryArray, tagArray, nameArray) => { + const queryFilters = []; + const categoryFilter = []; + const authorFilter = []; + const tagFilter = []; + if (categoryArray) { + categoryFilter.push({ '$Categories.name$': { [Op.in]: categoryArray } }); + queryFilters.push({ [Op.or]: categoryFilter }); + } + if (tagArray) { + tagFilter.push({ '$Tags.name$': { [Op.in]: tagArray } }); + queryFilters.push({ [Op.or]: tagFilter }); + } + if (nameArray) { + nameArray.forEach((item) => { + authorFilter.push({ '$author.firstname$': { [Op.iLike]: `%${item}%` } }); + authorFilter.push({ '$author.lastname$': { [Op.iLike]: `%${item}%` } }); + }); + queryFilters.push({ [Op.or]: authorFilter }); + } + return queryFilters; +}; + +/** + * @Module ArticleSearch + * @description Searches for an article using different filers + */ +class ArticleSearch { + /** + * @static + * @param {string} searchWord - search word + * @param {string} searchlimit - searchlimit + * @param {string} pageoffset - pageoffset + * @returns {null} - no value + * @memberof Blacklist + */ + static async querySearch(searchWord, searchlimit, pageoffset) { + const limit = searchlimit || 20; + const offset = pageoffset || 0; + const queryFields = searchQuery(searchWord); + const articles = await Article.findAndCountAll({ + limit, + offset, + where: { + [Op.or]: queryFields + }, + include: [{ model: User, as: 'author', attributes: ['firstname', 'lastname', 'username', 'image'] }, + { + model: Categories, attributes: ['name'], through: { attributes: [] }, duplicating: false + }, + { + model: Tags, attributes: ['name'], through: { attributes: [] }, duplicating: false + }, + ], + }); + return articles; + } + + + /** + * @static + * @param {string} searchWord - search word + * @param {string} filterObject - filters + * @param {string} searchlimit - limit + * @param {string} pageoffset - offset + * @returns {null} - no value + * @memberof Blacklist + */ + static async filterSearch(searchWord, filterObject, searchlimit, pageoffset) { + const limit = searchlimit || 20; + const offset = pageoffset || 0; + const { categories, tags, authorNames } = filterObject[Object.keys(filterObject)[0]]; + const categoryArray = categories && categories.split(/[ ,]/); + const tagArray = tags && tags.split(/[ ,]/); + const nameArray = authorNames && authorNames.split(/[ ,]/); + const queryFilters = getSearchFilters(categoryArray, tagArray, nameArray); + const queryFields = searchQuery(searchWord); + const articles = await Article.findAndCountAll({ + limit, + offset, + include: [ + { + model: User, as: 'author', attributes: ['firstname', 'lastname', 'username', 'image'], duplicating: false + }, + { + model: Categories, attributes: ['name'], through: { attributes: [] }, duplicating: false + }, + { + model: Tags, attributes: ['name'], through: { attributes: [] }, duplicating: false + } + ], + where: { [Op.and]: [{ [Op.or]: queryFields }, { [Op.and]: queryFilters }] } + }); + return articles; + } +} + +export default ArticleSearch; diff --git a/server/helpers/index.js b/server/helpers/index.js index 0d5298a..489689f 100644 --- a/server/helpers/index.js +++ b/server/helpers/index.js @@ -3,11 +3,13 @@ import Auth from './auth'; import passwordHash from './passwordHash'; import Utilities from './Utilities'; import Mail from './mail/mail'; +import articleSearch from './articleSearch'; const { generateToken, verifyToken } = Auth; const { hashPassword, comparePassword } = passwordHash; const { errorStat, successStat } = Utilities; const { addToBlacklist, checkBlacklist } = blacklistToken; +const { querySearch, filterSearch } = articleSearch; export default { addToBlacklist, @@ -18,5 +20,7 @@ export default { errorStat, successStat, verifyToken, - Mail + Mail, + querySearch, + filterSearch }; diff --git a/server/helpers/paginate.js b/server/helpers/paginate.js index 9cd1082..cd38dbd 100644 --- a/server/helpers/paginate.js +++ b/server/helpers/paginate.js @@ -1,5 +1,8 @@ import models from '../db/models'; import utils from './Utilities'; +import articleSearch from './articleSearch'; + +const { querySearch, filterSearch } = articleSearch; /** * @Module Paginate @@ -14,15 +17,24 @@ class Paginate { * @memberof Paginate */ static async paginateArticles(req, res) { + const { searchQuery } = req.query; let { page, limit } = req.query; + const queryFilters = req.body; limit = parseInt(limit, 10) ? limit : 5; page = parseInt(page, 10) > 0 ? page : 1; const offset = (page - 1) * limit; - const articles = await models.Article.findAndCountAll({ - where: {}, - offset, - limit - }); + let articles; + if (!searchQuery) { + articles = await models.Article.findAndCountAll({ + where: {}, + offset, + limit + }); + } else if (searchQuery && Object.keys(queryFilters)[0] !== 'undefined') { + articles = await filterSearch(searchQuery, queryFilters, limit, offset); + } else { + articles = await querySearch(searchQuery, limit, offset); + } if (articles.rows.length < 1) { return utils.errorStat(res, 404, 'Page not found'); } diff --git a/server/middlewares/index.js b/server/middlewares/index.js index 82118fb..9b1d94f 100644 --- a/server/middlewares/index.js +++ b/server/middlewares/index.js @@ -1,8 +1,10 @@ import Authenticate from './authenticate'; import InputValidator from './inputValidator'; import { multerUploads } from './multer'; +import searchValidator from './searchValidator'; const { verifyToken, optionalLogin } = Authenticate; +const { validateFilter, validateKeyword } = searchValidator; const { validateLogin, validateUser, @@ -23,5 +25,7 @@ export default { validatePasswordReset, validateEmail, optionalLogin, - validateCommentMessage + validateCommentMessage, + validateFilter, + validateKeyword }; diff --git a/server/middlewares/schema.js b/server/middlewares/schema.js index 8dc1bc3..c32b574 100644 --- a/server/middlewares/schema.js +++ b/server/middlewares/schema.js @@ -173,8 +173,24 @@ export const resetEmailSchema = { .email({ minDomainSegments: 2 }) .required() }; + export const commentBodySchema = { comment: Joi.string() .trim() .required() }; + +export const searchFilterSchema = { + searchQuery: Joi.string().trim().min(2), + page: Joi.number().integer().optional(), + limit: Joi.number().integer().optional(), + categories: Joi.string().allow('').trim(), + authorNames: Joi.string().allow('').trim(), + tags: Joi.string().allow('').trim(), +}; + +export const searchQuerySchema = { + searchQuery: Joi.string().allow('').trim().min(2), + page: Joi.number().integer().optional(), + limit: Joi.number().integer().optional() +}; diff --git a/server/middlewares/searchValidator.js b/server/middlewares/searchValidator.js new file mode 100644 index 0000000..fd4fb33 --- /dev/null +++ b/server/middlewares/searchValidator.js @@ -0,0 +1,37 @@ +import { searchFilterSchema, searchQuerySchema } from './schema'; +import validate from '../helpers/validate'; + +/** + * @class searchValidator + * @description Validates all user inputs + * @exports InputValidator + */ +class SearchValidator { + /** + * @method validateFilter + * @description Validates filters when user searchs for an article + * @param {object} req - The Request Object + * @param {object} res - The Response Object + * @param {function} next - The next function to point to the next middleware + * @returns {function} validate() - An execucted validate function + */ + static validateFilter(req, res, next) { + const filters = { ...req.query, ...req.body }; + return validate(filters, searchFilterSchema, req, res, next); + } + + /** + * @method validateSearchQuery + * @description Validates keyword query when user searchs for an article + * @param {object} req - The Request Object + * @param {object} res - The Response Object + * @param {function} next - The next function to point to the next middleware + * @returns {function} validate() - An execucted validate function + */ + static validateKeyword(req, res, next) { + const filters = req.query; + return validate(filters, searchQuerySchema, req, res, next); + } +} + +export default SearchValidator; diff --git a/server/routes/article.js b/server/routes/article.js index dc6cf94..5a6076c 100644 --- a/server/routes/article.js +++ b/server/routes/article.js @@ -4,7 +4,9 @@ import middlewares from '../middlewares'; const router = express.Router(); -const { validateArticle, multerUploads, verifyToken } = middlewares; +const { + validateArticle, multerUploads, verifyToken, validateFilter, validateKeyword +} = middlewares; const { createArticle, @@ -14,9 +16,14 @@ const { deleteArticle, } = ArticleController; router.post('/', verifyToken, validateArticle, createArticle); -router.get('/', getAllArticles); + +// gets all article with option of passing a keyoword as query +router.get('/', validateKeyword, getAllArticles); router.get('/:slug', getOneArticle); router.put('/:slug/edit', verifyToken, multerUploads, editArticle); router.delete('/:slug', verifyToken, deleteArticle); +// filters article search result based on selected filters +router.post('/search/filter', validateFilter, getAllArticles); + export default router; diff --git a/server/tests/article.test.js b/server/tests/article.test.js index 66ea6a1..631bc6f 100644 --- a/server/tests/article.test.js +++ b/server/tests/article.test.js @@ -326,3 +326,163 @@ describe('Article test', () => { }); }); }); + +describe('Search for an article', () => { + it('Should return 400 if categories is a number', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter`) + .send({ + categories: 543234 + }) + .end((err, res) => { + expect(res.body).to.have.a.status(400); + expect(res.body).to.include.all.keys('status', 'error'); + expect(res.body.error[0]).to.be.equal('categories must be a string'); + done(); + }); + }); + it('Should return 400 if searchQuery is less than two characters', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter?searchQuery=a`) + .send({ + categories: 'technoloy' + }) + .end((err, res) => { + expect(res.body).to.have.a.status(400); + expect(res.body).to.include.all.keys('status', 'error'); + expect(res.body.error[0]).to.be.equal('searchQuery length must be at least 2 characters long'); + done(); + }); + }); + it('Should return 400 if authorNames is a number', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter`) + .send({ + authorNames: 543234 + }) + .end((err, res) => { + expect(res).to.have.a.status(400); + expect(res.body).to.include.all.keys('status', 'error'); + expect(res.body.error[0]).to.be.equal('authorNames must be a string'); + done(); + }); + }); + it('Should return 400 if authorNames is a number', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter`) + .send({ + tags: 543234 + }) + .end((err, res) => { + expect(res).to.have.a.status(400); + expect(res.body).to.include.all.keys('status', 'error'); + expect(res.body.error[0]).to.be.equal('tags must be a string'); + done(); + }); + }); + it('Should return 400 if limit or page is not a number', (done) => { + chai.request(app) + .get(`${baseUrl}/articles?limit=erwer`) + .end((err, res) => { + expect(res).to.have.a.status(400); + expect(res.body).to.include.all.keys('status', 'error'); + expect(res.body.error[0]).to.be.equal('limit must be a number'); + done(); + }); + }); + it('Should get all articles with search query provided', (done) => { + chai.request(app) + .get(`${baseUrl}/articles?searchQuery=on`) + .end((err, res) => { + expect(res).to.have.a.status(200); + expect(res.body).to.include.all.keys('status', 'articles'); + expect(res.body.articles.rows).to.be.an('array'); + expect(res.body.articles.rows[0]).to.include.all.keys('title', 'articleBody', 'description', 'author', 'Categories', 'Tags'); + done(); + }); + }); + it('Should get all articles with search query provided include limit and/or page', (done) => { + chai.request(app) + .get(`${baseUrl}/articles?searchQuery=on&limit=20&page=1`) + .end((err, res) => { + expect(res).to.have.a.status(200); + expect(res.body).to.include.all.keys('status', 'articles'); + expect(res.body.articles.rows).to.be.an('array'); + expect(res.body.articles.rows[0]).to.include.all.keys('title', 'articleBody', 'description', 'author', 'Categories', 'Tags'); + done(); + }); + }); + it('Should get all articles with the query string a categories listed', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter?searchQuery=an`) + .send({ + categories: 'technology,health,fashion,lifestyle,education' + }) + .end((err, res) => { + expect(res).to.have.a.status(200); + expect(res.body).to.include.all.keys('status', 'articles'); + expect(res.body.articles.rows).to.be.an('array'); + expect(res.body.articles.rows[0]).to.include.all.keys('title', 'articleBody', 'description', 'author', 'Categories', 'Tags'); + done(); + }); + }); + it('Should get all articles that include the query string and the tag(s) listed', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter?searchQuery=an`) + .send({ + tags: 'javascript,love,ai,react,tutorial,believe' + }) + .end((err, res) => { + expect(res).to.have.a.status(200); + expect(res.body).to.include.all.keys('status', 'articles'); + expect(res.body.articles.rows).to.be.an('array'); + expect(res.body.articles.rows[0]).to.include.all.keys('title', 'articleBody', 'description', 'author', 'Categories', 'Tags'); + done(); + }); + }); + it('Should get all articles that include the query string ad the tag(s) listed including limit and/or page', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter?searchQuery=an&limit=10&page=1`) + .send({ + tags: 'javascript,love,ai,react,tutorial,believe' + }) + .end((err, res) => { + expect(res).to.have.a.status(200); + expect(res.body).to.include.all.keys('status', 'articles'); + expect(res.body.articles.rows).to.be.an('array'); + expect(res.body.articles.rows[0]).to.include.all.keys('title', 'articleBody', 'description', 'author', 'Categories', 'Tags'); + done(); + }); + }); + it('Should get all articles with query string, the categories and the tags', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter?searchQuery=an`) + .send({ + categories: 'fashion,technology,health,lifestyle,education', + tags: 'believe,race,react,tutorial,knowledge,javascript' + }) + .end((err, res) => { + expect(res).to.have.a.status(200); + expect(res.body).to.include.all.keys('status', 'articles'); + expect(res.body.articles.rows).to.be.an('array'); + expect(res.body.articles.rows[0]).to.include.all.keys('title', 'articleBody', 'description', 'author', 'Categories', 'Tags'); + done(); + }); + }); + it('Should get all articles with the query string, categories, tags and authorName', (done) => { + chai.request(app) + .post(`${baseUrl}/articles/search/filter?searchQuery=an`) + .send({ + categories: 'fashion,technology,health,fashion,lifestyle,education', + tags: 'believe,race,react,tutorial,knowledge,javascript,ai', + authorNames: 'dominic,monday,jude,kafilat,chukwudi,martins' + }) + .end((err, res) => { + expect(res).to.have.a.status(200); + expect(res.body).to.include.all.keys('status', 'articles'); + expect(res.body.articles.rows).to.be.an('array'); + expect(res.body.articles.rows[0]).to.include.all.keys('title', 'articleBody', 'description', 'author', 'Categories', 'Tags'); + done(); + }); + }); +}); diff --git a/server/tests/comment.test.js b/server/tests/comment.test.js index a9c33f9..956d30c 100644 --- a/server/tests/comment.test.js +++ b/server/tests/comment.test.js @@ -78,7 +78,7 @@ describe('Handle Comment', () => { it('Should fail if post is not found', (done) => { chai .request(app) - .post(`${baseUrl}/comment/${23}`) + .post(`${baseUrl}/comment/${230}`) .set('Authorization', `${userToken}`) .send(commentData[0]) .end((err, res) => {