diff --git a/controllers/goto/ideas.js b/controllers/goto/ideas.js index 2dca1d3..33d63ff 100644 --- a/controllers/goto/ideas.js +++ b/controllers/goto/ideas.js @@ -11,6 +11,7 @@ module.exports = { withCreators: route(['query.filter.creators']), commentedBy: route(['query.filter.commentedBy']), highlyVoted: route(['query.filter.highlyVoted']), - trending: route(['query.filter.trending']) + trending: route(['query.filter.trending']), + searchTitle: route(['query.filter.title.like']) }, }; diff --git a/controllers/ideas.js b/controllers/ideas.js index 594a1af..ef5e1de 100644 --- a/controllers/ideas.js +++ b/controllers/ideas.js @@ -276,4 +276,27 @@ async function getIdeasTrending(req, res, next) { } } -module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post }; +/** + * Get ideas with any of specified keywords in title + */ +async function getIdeasSearchTitle(req, res, next) { + try { + // gather data + const { page: { offset = 0, limit = 10 } = { } } = req.query; + const { like: keywords } = req.query.filter.title; + + // read ideas from database + const foundIdeas = await models.idea.findWithTitleKeywords(keywords, { offset, limit }); + // serialize + const serializedIdeas = serialize.idea(foundIdeas); + + // respond + return res.status(200).json(serializedIdeas); + + } catch (e) { + return next(e); + } +} + + +module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasSearchTitle, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post }; diff --git a/controllers/validators/ideas.js b/controllers/validators/ideas.js index fba73ce..ba82481 100644 --- a/controllers/validators/ideas.js +++ b/controllers/validators/ideas.js @@ -6,6 +6,7 @@ module.exports = { get: validate('getIdea'), getIdeasCommentedBy: validate('getIdeasCommentedBy'), getIdeasHighlyVoted: validate('getIdeasHighlyVoted'), + getIdeasSearchTitle: validate('getIdeasSearchTitle'), getIdeasTrending: validate('getIdeasTrending'), getIdeasWithCreators: validate('getIdeasWithCreators'), getIdeasWithMyTags: validate('getIdeasWithMyTags'), diff --git a/controllers/validators/parser.js b/controllers/validators/parser.js index 057e53f..fdba796 100644 --- a/controllers/validators/parser.js +++ b/controllers/validators/parser.js @@ -28,7 +28,10 @@ const parametersDictionary = { size: 'int', creators: 'array', commentedBy: 'array', - highlyVoted: 'int' + highlyVoted: 'int', + title: { + like: 'array' + } }, }; diff --git a/controllers/validators/schema/definitions.js b/controllers/validators/schema/definitions.js index d9ec66f..ea820bd 100644 --- a/controllers/validators/schema/definitions.js +++ b/controllers/validators/schema/definitions.js @@ -1,6 +1,6 @@ 'use strict'; -const { tagname, username } = require('./paths'); +const { tagname, title, username } = require('./paths'); module.exports = { shared: { @@ -174,6 +174,12 @@ module.exports = { items: username, maxItems: 10, minItems: 1 + }, + keywordsList: { + type: 'array', + items: title, + maxItems: 10, + minItems: 1 } } }; diff --git a/controllers/validators/schema/ideas.js b/controllers/validators/schema/ideas.js index 6c452a2..0699934 100644 --- a/controllers/validators/schema/ideas.js +++ b/controllers/validators/schema/ideas.js @@ -1,7 +1,6 @@ 'use strict'; -const { title, detail, id, page, pageOffset0, random, tagsList, usersList } = require('./paths'); - +const { title, detail, id, keywordsList, page, pageOffset0, random, tagsList, usersList } = require('./paths'); const postIdeas = { properties: { body: { @@ -218,4 +217,30 @@ const getIdeasTrending = { required: ['query'] }; -module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas }; +const getIdeasSearchTitle = { + properties: { + query: { + properties: { + filter: { + properties: { + title: { + properties: { + like: keywordsList + }, + required: ['like'], + additionalProperties: false + } + }, + required: ['title'], + additionalProperties: false + }, + page + }, + required: ['filter'], + additionalProperties: false + }, + }, + required: ['query'] +}; + +module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasSearchTitle, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas }; diff --git a/controllers/validators/schema/paths.js b/controllers/validators/schema/paths.js index 66855ce..2919065 100644 --- a/controllers/validators/schema/paths.js +++ b/controllers/validators/schema/paths.js @@ -21,6 +21,7 @@ module.exports = { random: { $ref: 'sch#/definitions/query/random' }, tagsList: { $ref: 'sch#/definitions/query/tagsList' }, usersList: { $ref: 'sch#/definitions/query/usersList' }, + keywordsList: { $ref: 'sch#/definitions/query/keywordsList' }, ideaId: { $ref : 'sch#/definitions/idea/ideaId' }, title: { $ref: 'sch#/definitions/idea/titl' }, detail: { $ref: 'sch#/definitions/idea/detail' }, diff --git a/models/idea/index.js b/models/idea/index.js index 3a984e4..f64f8f4 100644 --- a/models/idea/index.js +++ b/models/idea/index.js @@ -352,6 +352,38 @@ class Idea extends Model { const cursor = await this.db.query(query, params); return await cursor.all(); } + + + /** + * Read ideas with any of specified keywords in the title + * @param {string[]} keywords - list of keywords to search with + * @param {integer} offset - pagination offset + * @param {integer} limit - pagination limit + * @returns {Promise} - list of found ideas + */ + static async findWithTitleKeywords(keywords, { offset, limit }) { + const query = ` + FOR idea IN ideas + LET search = ( FOR keyword in @keywords + RETURN TO_NUMBER(CONTAINS(idea.title, keyword))) + LET fit = SUM(search) + FILTER fit > 0 + // find creator + LET c = (DOCUMENT(idea.creator)) + // format for output + LET creator = MERGE(KEEP(c, 'username'), c.profile) + LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }, {fit}) + // sort from newest + SORT fit DESC, ideaOut.title + // limit + LIMIT @offset, @limit + // respond + RETURN ideaOut`; + + const params = { 'keywords': keywords, offset, limit }; + const cursor = await this.db.query(query, params); + return await cursor.all(); + } } diff --git a/routes/ideas.js b/routes/ideas.js index 530327e..f295b0d 100644 --- a/routes/ideas.js +++ b/routes/ideas.js @@ -48,6 +48,11 @@ router.route('/') router.route('/') .get(go.get.trending, authorize.onlyLogged, parse, ideaValidators.getIdeasTrending, ideaControllers.getIdeasTrending); +// get ideas with keywords +router.route('/') + .get(go.get.searchTitle, authorize.onlyLogged, parse, ideaValidators.getIdeasSearchTitle, ideaControllers.getIdeasSearchTitle); + + router.route('/:id') // read idea by id .get(authorize.onlyLogged, ideaValidators.get, ideaControllers.get) diff --git a/test/ideas.list.js b/test/ideas.list.js index a7b3227..2a56940 100644 --- a/test/ideas.list.js +++ b/test/ideas.list.js @@ -1006,4 +1006,143 @@ describe('read lists of ideas', () => { }); }); }); + + describe('GET /ideas?filter[title][like]=string1,string2,string3', () => { + let user0; + // create and save testing data + beforeEach(async () => { + const data = { + users: 2, + verifiedUsers: [0], + ideas: [ [{title:'idea-title1'}, 0], [{title:'idea-title2-keyword1'}, 0], [{title:'idea-title3-keyword2'}, 0], [{title:'idea-title4-keyword3'}, 0], [{title:'idea-title5-keyword2-keyword3'}, 0], [{title:'idea-title6-keyword1'}, 0], [{title:'idea-title7-keyword1-keyword4'}, 0] ] + }; + + dbData = await dbHandle.fill(data); + + [user0, ] = dbData.users; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(user0); + }); + + context('valid data', () => { + + it('[find ideas with one word] 200 and return array of matched ideas', async () => { + + // request + const response = await agent + .get('/ideas?filter[title][like]=keyword1') + .expect(200); + + // we should find 2 ideas... + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(idea => idea.attributes.title)) + .eql(['idea-title2-keyword1','idea-title6-keyword1', 'idea-title7-keyword1-keyword4']); + + }); + + + it('[find ideas with two words] 200 and return array of matched ideas', async () => { + + // request + const response = await agent + .get('/ideas?filter[title][like]=keyword2,keyword3') + .expect(200); + + // we should find 4 ideas... + should(response.body).have.property('data').Array().length(3); + + // sorted by creation date desc + should(response.body.data.map(idea => idea.attributes.title)) + .eql(['idea-title5-keyword2-keyword3', 'idea-title3-keyword2', 'idea-title4-keyword3']); + }); + + it('[find ideas with word not present in any] 200 and return array of matched ideas', async () => { + + // request + const response = await agent + .get('/ideas?filter[title][like]=keyword10') + .expect(200); + + // we should find 0 ideas... + should(response.body).have.property('data').Array().length(0); + + }); + + it('[pagination] offset and limit the results', async () => { + const response = await agent + .get('/ideas?filter[title][like]=keyword1&page[offset]=1&page[limit]=2') + .expect(200); + + // we should find 3 ideas + should(response.body).have.property('data').Array().length(2); + + // sorted by creation date desc + should(response.body.data.map(idea => idea.attributes.title)) + .eql(['idea-title6-keyword1', 'idea-title7-keyword1-keyword4']); + }); + + it('should be fine to provide a keyword which includes empty spaces and/or special characters', async () => { + // request + await agent + .get('/ideas?filter[title][like]=keyword , aa,1-i') + .expect(200); + }); + + }); + + context('invalid data', () => { + + it('[too many keywords] 400', async () => { + await agent + .get('/ideas?filter[title][like]=keyword1,keyword2,keyword3,keyword4,keyword5,keyword6,keyword7,keyword8,keyword9,keyword10,keyword11') + .expect(400); + }); + + it('[empty keywords] 400', async () => { + await agent + .get('/ideas?filter[title][like]=keyword1,') + .expect(400); + }); + + it('[too long keywords] 400', async () => { + await agent + .get(`/ideas?filter[title][like]=keyword1,${'a'.repeat(257)}`) + .expect(400); + }); + + it('[keywords spaces only] 400', async () => { + await agent + .get('/ideas?filter[title][like]= ,keyword2') + .expect(400); + }); + + it('[invalid pagination] 400', async () => { + await agent + .get('/ideas?filter[title][like]=keyword1&page[offset]=1&page[limit]=21') + .expect(400); + }); + + it('[unexpected query params] 400', async () => { + await agent + .get('/ideas?filter[title][like]=keyword1&additional[param]=3&page[offset]=1&page[limit]=3') + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent + .get('/ideas?filter[title][like]=keyword1') + .expect(403); + }); + }); + }); + });