Browse Source

create GET /ideas?filter[title][like] (#62)

* create GET /ideas?filter[title][like]

* fix validator, remove .only from test

* added tests and validation for query items
Agata Andrzejewska 2 years ago
parent
commit
70d2cc7b92
No account linked to committer's email

+ 2
- 1
controllers/goto/ideas.js View File

@@ -11,6 +11,7 @@ module.exports = {
11 11
     withCreators: route(['query.filter.creators']),
12 12
     commentedBy: route(['query.filter.commentedBy']),
13 13
     highlyVoted: route(['query.filter.highlyVoted']),
14
-    trending: route(['query.filter.trending'])
14
+    trending: route(['query.filter.trending']),
15
+    searchTitle: route(['query.filter.title.like'])
15 16
   },
16 17
 };

+ 24
- 1
controllers/ideas.js View File

@@ -276,4 +276,27 @@ async function getIdeasTrending(req, res, next) {
276 276
   }
277 277
 }
278 278
 
279
-module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post };
279
+/**
280
+ * Get ideas with any of specified keywords in title
281
+ */
282
+async function getIdeasSearchTitle(req, res, next) {
283
+  try {
284
+    // gather data
285
+    const { page: { offset = 0, limit = 10 } = { } } = req.query;
286
+    const { like: keywords } = req.query.filter.title;
287
+
288
+    // read ideas from database
289
+    const foundIdeas = await models.idea.findWithTitleKeywords(keywords, { offset, limit });
290
+    // serialize
291
+    const serializedIdeas = serialize.idea(foundIdeas);
292
+
293
+    // respond
294
+    return res.status(200).json(serializedIdeas);
295
+
296
+  } catch (e) {
297
+    return next(e);
298
+  }
299
+}
300
+
301
+
302
+module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasSearchTitle, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post };

+ 1
- 0
controllers/validators/ideas.js View File

@@ -6,6 +6,7 @@ module.exports = {
6 6
   get: validate('getIdea'),
7 7
   getIdeasCommentedBy: validate('getIdeasCommentedBy'),
8 8
   getIdeasHighlyVoted: validate('getIdeasHighlyVoted'),
9
+  getIdeasSearchTitle: validate('getIdeasSearchTitle'),
9 10
   getIdeasTrending: validate('getIdeasTrending'),
10 11
   getIdeasWithCreators: validate('getIdeasWithCreators'),
11 12
   getIdeasWithMyTags: validate('getIdeasWithMyTags'),

+ 4
- 1
controllers/validators/parser.js View File

@@ -28,7 +28,10 @@ const parametersDictionary = {
28 28
     size: 'int',
29 29
     creators: 'array',
30 30
     commentedBy: 'array',
31
-    highlyVoted: 'int'
31
+    highlyVoted: 'int',
32
+    title: {
33
+      like: 'array'
34
+    }
32 35
   },
33 36
 };
34 37
 

+ 7
- 1
controllers/validators/schema/definitions.js View File

@@ -1,6 +1,6 @@
1 1
 'use strict';
2 2
 
3
-const { tagname, username } = require('./paths');
3
+const { tagname, title, username } = require('./paths');
4 4
 
5 5
 module.exports = {
6 6
   shared: {
@@ -174,6 +174,12 @@ module.exports = {
174 174
       items: username,
175 175
       maxItems: 10,
176 176
       minItems: 1
177
+    },
178
+    keywordsList: {
179
+      type: 'array',
180
+      items: title,
181
+      maxItems: 10,
182
+      minItems: 1
177 183
     }
178 184
   }
179 185
 };

+ 28
- 3
controllers/validators/schema/ideas.js View File

@@ -1,7 +1,6 @@
1 1
 'use strict';
2 2
 
3
-const { title, detail, id, page, pageOffset0, random, tagsList, usersList } = require('./paths');
4
-
3
+const { title, detail, id, keywordsList, page, pageOffset0, random, tagsList, usersList } = require('./paths');
5 4
 const postIdeas = {
6 5
   properties: {
7 6
     body: {
@@ -218,4 +217,30 @@ const getIdeasTrending = {
218 217
   required: ['query']
219 218
 };
220 219
 
221
-module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas };
220
+const getIdeasSearchTitle = {
221
+  properties: {
222
+    query: {
223
+      properties: {
224
+        filter: {
225
+          properties: {
226
+            title: {
227
+              properties: {
228
+                like: keywordsList
229
+              },
230
+              required: ['like'],
231
+              additionalProperties: false
232
+            }
233
+          },
234
+          required: ['title'],
235
+          additionalProperties: false
236
+        },
237
+        page
238
+      },
239
+      required: ['filter'],
240
+      additionalProperties: false
241
+    },
242
+  },
243
+  required: ['query']
244
+};
245
+
246
+module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasSearchTitle, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas };

+ 1
- 0
controllers/validators/schema/paths.js View File

@@ -21,6 +21,7 @@ module.exports = {
21 21
   random: { $ref: 'sch#/definitions/query/random' },
22 22
   tagsList: { $ref: 'sch#/definitions/query/tagsList' },
23 23
   usersList: { $ref: 'sch#/definitions/query/usersList' },
24
+  keywordsList: { $ref: 'sch#/definitions/query/keywordsList' },
24 25
   ideaId: { $ref : 'sch#/definitions/idea/ideaId' },
25 26
   title: { $ref: 'sch#/definitions/idea/titl' },
26 27
   detail: { $ref: 'sch#/definitions/idea/detail' },

+ 32
- 0
models/idea/index.js View File

@@ -352,6 +352,38 @@ class Idea extends Model {
352 352
     const cursor = await this.db.query(query, params);
353 353
     return await cursor.all();
354 354
   }
355
+
356
+
357
+  /**
358
+   * Read ideas with any of specified keywords in the title
359
+   * @param {string[]} keywords - list of keywords to search with
360
+   * @param {integer} offset - pagination offset
361
+   * @param {integer} limit - pagination limit
362
+   * @returns {Promise<Idea[]>} - list of found ideas
363
+   */
364
+  static async findWithTitleKeywords(keywords, { offset, limit }) {
365
+    const query = `
366
+          FOR idea IN ideas 
367
+            LET search = ( FOR keyword in @keywords
368
+                            RETURN TO_NUMBER(CONTAINS(idea.title, keyword)))
369
+            LET fit = SUM(search)
370
+            FILTER fit > 0
371
+            // find creator
372
+            LET c = (DOCUMENT(idea.creator))
373
+            // format for output
374
+            LET creator = MERGE(KEEP(c, 'username'), c.profile)
375
+            LET ideaOut = MERGE(KEEP(idea, 'title', 'detail', 'created'), { id: idea._key}, { creator }, {fit})
376
+            // sort from newest
377
+            SORT fit DESC, ideaOut.title
378
+            // limit
379
+            LIMIT @offset, @limit
380
+            // respond
381
+            RETURN ideaOut`;
382
+
383
+    const params = { 'keywords': keywords, offset, limit };
384
+    const cursor = await this.db.query(query, params);
385
+    return await cursor.all();
386
+  }
355 387
 }
356 388
 
357 389
 

+ 5
- 0
routes/ideas.js View File

@@ -48,6 +48,11 @@ router.route('/')
48 48
 router.route('/')
49 49
   .get(go.get.trending, authorize.onlyLogged, parse, ideaValidators.getIdeasTrending, ideaControllers.getIdeasTrending);
50 50
 
51
+// get ideas with keywords
52
+router.route('/')
53
+  .get(go.get.searchTitle, authorize.onlyLogged, parse, ideaValidators.getIdeasSearchTitle, ideaControllers.getIdeasSearchTitle);
54
+
55
+
51 56
 router.route('/:id')
52 57
   // read idea by id
53 58
   .get(authorize.onlyLogged, ideaValidators.get, ideaControllers.get)

+ 139
- 0
test/ideas.list.js View File

@@ -1006,4 +1006,143 @@ describe('read lists of ideas', () => {
1006 1006
       });
1007 1007
     });
1008 1008
   });
1009
+
1010
+  describe('GET /ideas?filter[title][like]=string1,string2,string3', () => {
1011
+    let user0;
1012
+    // create and save testing data
1013
+    beforeEach(async () => {
1014
+      const data = {
1015
+        users: 2,
1016
+        verifiedUsers: [0],
1017
+        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] ]
1018
+      };
1019
+
1020
+      dbData = await dbHandle.fill(data);
1021
+
1022
+      [user0, ] = dbData.users;
1023
+    });
1024
+
1025
+    context('logged in', () => {
1026
+
1027
+      beforeEach(() => {
1028
+        agent = agentFactory.logged(user0);
1029
+      });
1030
+
1031
+      context('valid data', () => {
1032
+
1033
+        it('[find ideas with one word] 200 and return array of matched ideas', async () => {
1034
+
1035
+          // request
1036
+          const response = await agent
1037
+            .get('/ideas?filter[title][like]=keyword1')
1038
+            .expect(200);
1039
+
1040
+          // we should find 2 ideas...
1041
+          should(response.body).have.property('data').Array().length(3);
1042
+
1043
+          // sorted by creation date desc
1044
+          should(response.body.data.map(idea => idea.attributes.title))
1045
+            .eql(['idea-title2-keyword1','idea-title6-keyword1', 'idea-title7-keyword1-keyword4']);
1046
+
1047
+        });
1048
+
1049
+
1050
+        it('[find ideas with two words] 200 and return array of matched ideas', async () => {
1051
+
1052
+          // request
1053
+          const response = await agent
1054
+            .get('/ideas?filter[title][like]=keyword2,keyword3')
1055
+            .expect(200);
1056
+
1057
+          // we should find 4 ideas...
1058
+          should(response.body).have.property('data').Array().length(3);
1059
+
1060
+          // sorted by creation date desc
1061
+          should(response.body.data.map(idea => idea.attributes.title))
1062
+            .eql(['idea-title5-keyword2-keyword3', 'idea-title3-keyword2', 'idea-title4-keyword3']);
1063
+        });
1064
+
1065
+        it('[find ideas with word not present in any] 200 and return array of matched ideas', async () => {
1066
+
1067
+          // request
1068
+          const response = await agent
1069
+            .get('/ideas?filter[title][like]=keyword10')
1070
+            .expect(200);
1071
+
1072
+          // we should find 0 ideas...
1073
+          should(response.body).have.property('data').Array().length(0);
1074
+
1075
+        });
1076
+
1077
+        it('[pagination] offset and limit the results', async () => {
1078
+          const response = await agent
1079
+            .get('/ideas?filter[title][like]=keyword1&page[offset]=1&page[limit]=2')
1080
+            .expect(200);
1081
+
1082
+          // we should find 3 ideas
1083
+          should(response.body).have.property('data').Array().length(2);
1084
+
1085
+          // sorted by creation date desc
1086
+          should(response.body.data.map(idea => idea.attributes.title))
1087
+            .eql(['idea-title6-keyword1', 'idea-title7-keyword1-keyword4']);
1088
+        });
1089
+
1090
+        it('should be fine to provide a keyword which includes empty spaces and/or special characters', async () => {
1091
+          // request
1092
+          await agent
1093
+            .get('/ideas?filter[title][like]=keyword , aa,1-i')
1094
+            .expect(200);
1095
+        });
1096
+
1097
+      });
1098
+
1099
+      context('invalid data', () => {
1100
+
1101
+        it('[too many keywords] 400', async () => {
1102
+          await agent
1103
+            .get('/ideas?filter[title][like]=keyword1,keyword2,keyword3,keyword4,keyword5,keyword6,keyword7,keyword8,keyword9,keyword10,keyword11')
1104
+            .expect(400);
1105
+        });
1106
+
1107
+        it('[empty keywords] 400', async () => {
1108
+          await agent
1109
+            .get('/ideas?filter[title][like]=keyword1,')
1110
+            .expect(400);
1111
+        });
1112
+
1113
+        it('[too long keywords] 400', async () => {
1114
+          await agent
1115
+            .get(`/ideas?filter[title][like]=keyword1,${'a'.repeat(257)}`)
1116
+            .expect(400);
1117
+        });
1118
+
1119
+        it('[keywords spaces only] 400', async () => {
1120
+          await agent
1121
+            .get('/ideas?filter[title][like]=  ,keyword2')
1122
+            .expect(400);
1123
+        });
1124
+
1125
+        it('[invalid pagination] 400', async () => {
1126
+          await agent
1127
+            .get('/ideas?filter[title][like]=keyword1&page[offset]=1&page[limit]=21')
1128
+            .expect(400);
1129
+        });
1130
+
1131
+        it('[unexpected query params] 400', async () => {
1132
+          await agent
1133
+            .get('/ideas?filter[title][like]=keyword1&additional[param]=3&page[offset]=1&page[limit]=3')
1134
+            .expect(400);
1135
+        });
1136
+      });
1137
+    });
1138
+
1139
+    context('not logged in', () => {
1140
+      it('403', async () => {
1141
+        await agent
1142
+          .get('/ideas?filter[title][like]=keyword1')
1143
+          .expect(403);
1144
+      });
1145
+    });
1146
+  });
1147
+
1009 1148
 });