├── .env
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── Conduit.postman_collection.json
├── README.md
├── model
├── arrows.html
└── arrows.svg
├── nest-cli.json
├── package-lock.json
├── package.json
├── project-logo.png
├── src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── article
│ ├── article.controller.spec.ts
│ ├── article.controller.ts
│ ├── article.module.ts
│ ├── article.service.spec.ts
│ ├── article.service.ts
│ ├── dto
│ │ ├── create-article.dto.ts
│ │ ├── create-comment.dto.ts
│ │ └── update-article.dto.ts
│ ├── entity
│ │ ├── article.entity.ts
│ │ ├── comment.entity.ts
│ │ └── tag.entity.ts
│ ├── tag
│ │ ├── tag.service.spec.ts
│ │ └── tag.service.ts
│ └── tags
│ │ ├── tags.controller.spec.ts
│ │ └── tags.controller.ts
├── main.ts
├── pipes
│ └── unprocessible-entity-validation.pipe.ts
└── user
│ ├── auth
│ ├── auth.service.ts
│ ├── jwt.auth-guard.ts
│ ├── jwt.strategy.ts
│ ├── local-auth.guard.ts
│ └── local.strategy.ts
│ ├── dto
│ ├── create-user.dto.ts
│ ├── login.dto.ts
│ └── update-user.dto.ts
│ ├── encryption
│ ├── encryption.service.spec.ts
│ └── encryption.service.ts
│ ├── entity
│ └── user.entity.ts
│ ├── profile
│ ├── profile.controller.spec.ts
│ └── profile.controller.ts
│ ├── user.controller.ts
│ ├── user.module.ts
│ ├── user.service.spec.ts
│ ├── user.service.ts
│ └── users.controller.ts
├── test
├── app.e2e-spec.ts
└── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
/.env:
--------------------------------------------------------------------------------
1 | NEO4J_SCHEME=neo4j
2 | NEO4J_PORT=7687
3 | NEO4J_HOST=localhost
4 | NEO4J_USERNAME=neo4j
5 | NEO4J_PASSWORD=neo
6 |
7 | HASH_ROUNDS=10
8 |
9 | JWT_SECRET=mySecret
10 | JWT_EXPIRES_IN=30d
11 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | sourceType: 'module',
6 | },
7 | plugins: ['@typescript-eslint/eslint-plugin'],
8 | extends: [
9 | 'plugin:@typescript-eslint/eslint-recommended',
10 | 'plugin:@typescript-eslint/recommended',
11 | 'prettier',
12 | 'prettier/@typescript-eslint',
13 | ],
14 | root: true,
15 | env: {
16 | node: true,
17 | jest: true,
18 | },
19 | rules: {
20 | '@typescript-eslint/interface-name-prefix': 'off',
21 | '@typescript-eslint/explicit-function-return-type': 'off',
22 | '@typescript-eslint/no-explicit-any': 'off',
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # OS
14 | .DS_Store
15 |
16 | # Tests
17 | /coverage
18 | /.nyc_output
19 |
20 | # IDEs and editors
21 | /.idea
22 | .project
23 | .classpath
24 | .c9/
25 | *.launch
26 | .settings/
27 | *.sublime-workspace
28 |
29 | # IDE - VSCode
30 | .vscode/*
31 | !.vscode/settings.json
32 | !.vscode/tasks.json
33 | !.vscode/launch.json
34 | !.vscode/extensions.json
35 |
36 | test/
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
--------------------------------------------------------------------------------
/Conduit.postman_collection.json:
--------------------------------------------------------------------------------
1 | {
2 | "info": {
3 | "_postman_id": "0574ad8a-a525-43ae-8e1e-5fd9756037f4",
4 | "name": "Conduit",
5 | "description": "Collection for testing the Conduit API\n\nhttps://github.com/gothinkster/realworld",
6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
7 | },
8 | "item": [
9 | {
10 | "name": "Auth",
11 | "item": [
12 | {
13 | "name": "Register",
14 | "event": [
15 | {
16 | "listen": "test",
17 | "script": {
18 | "type": "text/javascript",
19 | "exec": [
20 | "if (!(environment.isIntegrationTest)) {",
21 | "var responseJSON = JSON.parse(responseBody);",
22 | "",
23 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
24 | "",
25 | "var user = responseJSON.user || {};",
26 | "",
27 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
28 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
29 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
30 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
31 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
32 | "}",
33 | ""
34 | ]
35 | }
36 | }
37 | ],
38 | "request": {
39 | "method": "POST",
40 | "header": [
41 | {
42 | "key": "Content-Type",
43 | "value": "application/json"
44 | },
45 | {
46 | "key": "X-Requested-With",
47 | "value": "XMLHttpRequest"
48 | }
49 | ],
50 | "body": {
51 | "mode": "raw",
52 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"{{USERNAME}}\"}}"
53 | },
54 | "url": {
55 | "raw": "{{APIURL}}/users",
56 | "host": [
57 | "{{APIURL}}"
58 | ],
59 | "path": [
60 | "users"
61 | ]
62 | }
63 | },
64 | "response": []
65 | },
66 | {
67 | "name": "Login",
68 | "event": [
69 | {
70 | "listen": "test",
71 | "script": {
72 | "type": "text/javascript",
73 | "exec": [
74 | "var responseJSON = JSON.parse(responseBody);",
75 | "",
76 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
77 | "",
78 | "var user = responseJSON.user || {};",
79 | "",
80 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
81 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
82 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
83 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
84 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
85 | ""
86 | ]
87 | }
88 | }
89 | ],
90 | "request": {
91 | "method": "POST",
92 | "header": [
93 | {
94 | "key": "Content-Type",
95 | "value": "application/json"
96 | },
97 | {
98 | "key": "X-Requested-With",
99 | "value": "XMLHttpRequest"
100 | }
101 | ],
102 | "body": {
103 | "mode": "raw",
104 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}"
105 | },
106 | "url": {
107 | "raw": "{{APIURL}}/users/login",
108 | "host": [
109 | "{{APIURL}}"
110 | ],
111 | "path": [
112 | "users",
113 | "login"
114 | ]
115 | }
116 | },
117 | "response": []
118 | },
119 | {
120 | "name": "Login and Remember Token",
121 | "event": [
122 | {
123 | "listen": "test",
124 | "script": {
125 | "id": "a7674032-bf09-4ae7-8224-4afa2fb1a9f9",
126 | "type": "text/javascript",
127 | "exec": [
128 | "var responseJSON = JSON.parse(responseBody);",
129 | "",
130 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
131 | "",
132 | "var user = responseJSON.user || {};",
133 | "",
134 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
135 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
136 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
137 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
138 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
139 | "",
140 | "if(tests['User has \"token\" property']){",
141 | " pm.globals.set('token', user.token);",
142 | "}",
143 | "",
144 | "tests['Global variable \"token\" has been set'] = pm.globals.get('token') === user.token;",
145 | ""
146 | ]
147 | }
148 | }
149 | ],
150 | "request": {
151 | "method": "POST",
152 | "header": [
153 | {
154 | "key": "Content-Type",
155 | "value": "application/json"
156 | },
157 | {
158 | "key": "X-Requested-With",
159 | "value": "XMLHttpRequest"
160 | }
161 | ],
162 | "body": {
163 | "mode": "raw",
164 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}"
165 | },
166 | "url": {
167 | "raw": "{{APIURL}}/users/login",
168 | "host": [
169 | "{{APIURL}}"
170 | ],
171 | "path": [
172 | "users",
173 | "login"
174 | ]
175 | }
176 | },
177 | "response": []
178 | },
179 | {
180 | "name": "Current User",
181 | "event": [
182 | {
183 | "listen": "test",
184 | "script": {
185 | "type": "text/javascript",
186 | "exec": [
187 | "var responseJSON = JSON.parse(responseBody);",
188 | "",
189 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
190 | "",
191 | "var user = responseJSON.user || {};",
192 | "",
193 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
194 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
195 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
196 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
197 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
198 | ""
199 | ]
200 | }
201 | }
202 | ],
203 | "request": {
204 | "method": "GET",
205 | "header": [
206 | {
207 | "key": "Content-Type",
208 | "value": "application/json"
209 | },
210 | {
211 | "key": "X-Requested-With",
212 | "value": "XMLHttpRequest"
213 | },
214 | {
215 | "key": "Authorization",
216 | "value": "Token {{token}}"
217 | }
218 | ],
219 | "body": {
220 | "mode": "raw",
221 | "raw": ""
222 | },
223 | "url": {
224 | "raw": "{{APIURL}}/user",
225 | "host": [
226 | "{{APIURL}}"
227 | ],
228 | "path": [
229 | "user"
230 | ]
231 | }
232 | },
233 | "response": []
234 | },
235 | {
236 | "name": "Update User",
237 | "event": [
238 | {
239 | "listen": "test",
240 | "script": {
241 | "type": "text/javascript",
242 | "exec": [
243 | "var responseJSON = JSON.parse(responseBody);",
244 | "",
245 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
246 | "",
247 | "var user = responseJSON.user || {};",
248 | "",
249 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
250 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
251 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
252 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
253 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
254 | ""
255 | ]
256 | }
257 | }
258 | ],
259 | "request": {
260 | "method": "PUT",
261 | "header": [
262 | {
263 | "key": "Content-Type",
264 | "value": "application/json"
265 | },
266 | {
267 | "key": "X-Requested-With",
268 | "value": "XMLHttpRequest"
269 | },
270 | {
271 | "key": "Authorization",
272 | "value": "Token {{token}}"
273 | }
274 | ],
275 | "body": {
276 | "mode": "raw",
277 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}"
278 | },
279 | "url": {
280 | "raw": "{{APIURL}}/user",
281 | "host": [
282 | "{{APIURL}}"
283 | ],
284 | "path": [
285 | "user"
286 | ]
287 | }
288 | },
289 | "response": []
290 | }
291 | ]
292 | },
293 | {
294 | "name": "Articles",
295 | "item": [
296 | {
297 | "name": "All Articles",
298 | "event": [
299 | {
300 | "listen": "test",
301 | "script": {
302 | "type": "text/javascript",
303 | "exec": [
304 | "var is200Response = responseCode.code === 200;",
305 | "",
306 | "tests['Response code is 200 OK'] = is200Response;",
307 | "",
308 | "if(is200Response){",
309 | " var responseJSON = JSON.parse(responseBody);",
310 | "",
311 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
312 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
313 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
314 | "",
315 | " if(responseJSON.articles.length){",
316 | " var article = responseJSON.articles[0];",
317 | "",
318 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
319 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
320 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
321 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
322 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
323 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
324 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
325 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
326 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
327 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
328 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
329 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
330 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
331 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
332 | " } else {",
333 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
334 | " }",
335 | "}",
336 | ""
337 | ]
338 | }
339 | }
340 | ],
341 | "request": {
342 | "method": "GET",
343 | "header": [
344 | {
345 | "key": "Content-Type",
346 | "value": "application/json"
347 | },
348 | {
349 | "key": "X-Requested-With",
350 | "value": "XMLHttpRequest"
351 | }
352 | ],
353 | "body": {
354 | "mode": "raw",
355 | "raw": ""
356 | },
357 | "url": {
358 | "raw": "{{APIURL}}/articles",
359 | "host": [
360 | "{{APIURL}}"
361 | ],
362 | "path": [
363 | "articles"
364 | ]
365 | }
366 | },
367 | "response": []
368 | },
369 | {
370 | "name": "Articles by Author",
371 | "event": [
372 | {
373 | "listen": "test",
374 | "script": {
375 | "type": "text/javascript",
376 | "exec": [
377 | "var is200Response = responseCode.code === 200;",
378 | "",
379 | "tests['Response code is 200 OK'] = is200Response;",
380 | "",
381 | "if(is200Response){",
382 | " var responseJSON = JSON.parse(responseBody);",
383 | "",
384 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
385 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
386 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
387 | "",
388 | " if(responseJSON.articles.length){",
389 | " var article = responseJSON.articles[0];",
390 | "",
391 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
392 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
393 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
394 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
395 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
396 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
397 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
398 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
399 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
400 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
401 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
402 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
403 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
404 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
405 | " } else {",
406 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
407 | " }",
408 | "}",
409 | ""
410 | ]
411 | }
412 | }
413 | ],
414 | "request": {
415 | "method": "GET",
416 | "header": [
417 | {
418 | "key": "Content-Type",
419 | "value": "application/json"
420 | },
421 | {
422 | "key": "X-Requested-With",
423 | "value": "XMLHttpRequest"
424 | }
425 | ],
426 | "body": {
427 | "mode": "raw",
428 | "raw": ""
429 | },
430 | "url": {
431 | "raw": "{{APIURL}}/articles?author=johnjacob",
432 | "host": [
433 | "{{APIURL}}"
434 | ],
435 | "path": [
436 | "articles"
437 | ],
438 | "query": [
439 | {
440 | "key": "author",
441 | "value": "johnjacob"
442 | }
443 | ]
444 | }
445 | },
446 | "response": []
447 | },
448 | {
449 | "name": "Articles Favorited by Username",
450 | "event": [
451 | {
452 | "listen": "test",
453 | "script": {
454 | "type": "text/javascript",
455 | "exec": [
456 | "var is200Response = responseCode.code === 200;",
457 | "",
458 | "tests['Response code is 200 OK'] = is200Response;",
459 | "",
460 | "if(is200Response){",
461 | " var responseJSON = JSON.parse(responseBody);",
462 | " ",
463 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
464 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
465 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
466 | "",
467 | " if(responseJSON.articles.length){",
468 | " var article = responseJSON.articles[0];",
469 | "",
470 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
471 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
472 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
473 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
474 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
475 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
476 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
477 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
478 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
479 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
480 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
481 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
482 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
483 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
484 | " } else {",
485 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
486 | " }",
487 | "}",
488 | ""
489 | ]
490 | }
491 | }
492 | ],
493 | "request": {
494 | "method": "GET",
495 | "header": [
496 | {
497 | "key": "Content-Type",
498 | "value": "application/json"
499 | },
500 | {
501 | "key": "X-Requested-With",
502 | "value": "XMLHttpRequest"
503 | }
504 | ],
505 | "body": {
506 | "mode": "raw",
507 | "raw": ""
508 | },
509 | "url": {
510 | "raw": "{{APIURL}}/articles?favorited=jane",
511 | "host": [
512 | "{{APIURL}}"
513 | ],
514 | "path": [
515 | "articles"
516 | ],
517 | "query": [
518 | {
519 | "key": "favorited",
520 | "value": "jane"
521 | }
522 | ]
523 | }
524 | },
525 | "response": []
526 | },
527 | {
528 | "name": "Articles by Tag",
529 | "event": [
530 | {
531 | "listen": "test",
532 | "script": {
533 | "type": "text/javascript",
534 | "exec": [
535 | "var is200Response = responseCode.code === 200;",
536 | "",
537 | "tests['Response code is 200 OK'] = is200Response;",
538 | "",
539 | "if(is200Response){",
540 | " var responseJSON = JSON.parse(responseBody);",
541 | "",
542 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
543 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
544 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
545 | "",
546 | " if(responseJSON.articles.length){",
547 | " var article = responseJSON.articles[0];",
548 | "",
549 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
550 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
551 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
552 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
553 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
554 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
555 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
556 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
557 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
558 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
559 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
560 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
561 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
562 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
563 | " } else {",
564 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
565 | " }",
566 | "}",
567 | ""
568 | ]
569 | }
570 | }
571 | ],
572 | "request": {
573 | "method": "GET",
574 | "header": [
575 | {
576 | "key": "Content-Type",
577 | "value": "application/json"
578 | },
579 | {
580 | "key": "X-Requested-With",
581 | "value": "XMLHttpRequest"
582 | }
583 | ],
584 | "body": {
585 | "mode": "raw",
586 | "raw": ""
587 | },
588 | "url": {
589 | "raw": "{{APIURL}}/articles?tag=dragons",
590 | "host": [
591 | "{{APIURL}}"
592 | ],
593 | "path": [
594 | "articles"
595 | ],
596 | "query": [
597 | {
598 | "key": "tag",
599 | "value": "dragons"
600 | }
601 | ]
602 | }
603 | },
604 | "response": []
605 | }
606 | ]
607 | },
608 | {
609 | "name": "Articles, Favorite, Comments",
610 | "item": [
611 | {
612 | "name": "Create Article",
613 | "event": [
614 | {
615 | "listen": "test",
616 | "script": {
617 | "id": "e711dbf8-8065-4ba8-8b74-f1639a7d8208",
618 | "type": "text/javascript",
619 | "exec": [
620 | "var responseJSON = JSON.parse(responseBody);",
621 | "",
622 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
623 | "",
624 | "var article = responseJSON.article || {};",
625 | "",
626 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
627 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
628 | "pm.globals.set('slug', article.slug);",
629 | "",
630 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
631 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
632 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
633 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
634 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
635 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
636 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
637 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
638 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
639 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
640 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
641 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
642 | ""
643 | ]
644 | }
645 | }
646 | ],
647 | "request": {
648 | "method": "POST",
649 | "header": [
650 | {
651 | "key": "Content-Type",
652 | "value": "application/json"
653 | },
654 | {
655 | "key": "X-Requested-With",
656 | "value": "XMLHttpRequest"
657 | },
658 | {
659 | "key": "Authorization",
660 | "value": "Token {{token}}"
661 | }
662 | ],
663 | "body": {
664 | "mode": "raw",
665 | "raw": "{\"article\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"dragons\",\"training\"]}}"
666 | },
667 | "url": {
668 | "raw": "{{APIURL}}/articles",
669 | "host": [
670 | "{{APIURL}}"
671 | ],
672 | "path": [
673 | "articles"
674 | ]
675 | }
676 | },
677 | "response": []
678 | },
679 | {
680 | "name": "Feed",
681 | "event": [
682 | {
683 | "listen": "test",
684 | "script": {
685 | "type": "text/javascript",
686 | "exec": [
687 | "var is200Response = responseCode.code === 200;",
688 | "",
689 | "tests['Response code is 200 OK'] = is200Response;",
690 | "",
691 | "if(is200Response){",
692 | " var responseJSON = JSON.parse(responseBody);",
693 | "",
694 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
695 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
696 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
697 | "",
698 | " if(responseJSON.articles.length){",
699 | " var article = responseJSON.articles[0];",
700 | "",
701 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
702 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
703 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
704 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
705 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
706 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
707 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
708 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
709 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
710 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
711 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
712 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
713 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
714 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
715 | " } else {",
716 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
717 | " }",
718 | "}",
719 | ""
720 | ]
721 | }
722 | }
723 | ],
724 | "request": {
725 | "method": "GET",
726 | "header": [
727 | {
728 | "key": "Content-Type",
729 | "value": "application/json"
730 | },
731 | {
732 | "key": "X-Requested-With",
733 | "value": "XMLHttpRequest"
734 | },
735 | {
736 | "key": "Authorization",
737 | "value": "Token {{token}}"
738 | }
739 | ],
740 | "body": {
741 | "mode": "raw",
742 | "raw": ""
743 | },
744 | "url": {
745 | "raw": "{{APIURL}}/articles/feed",
746 | "host": [
747 | "{{APIURL}}"
748 | ],
749 | "path": [
750 | "articles",
751 | "feed"
752 | ]
753 | }
754 | },
755 | "response": []
756 | },
757 | {
758 | "name": "All Articles",
759 | "event": [
760 | {
761 | "listen": "test",
762 | "script": {
763 | "type": "text/javascript",
764 | "exec": [
765 | "var is200Response = responseCode.code === 200;",
766 | "",
767 | "tests['Response code is 200 OK'] = is200Response;",
768 | "",
769 | "if(is200Response){",
770 | " var responseJSON = JSON.parse(responseBody);",
771 | "",
772 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
773 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
774 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
775 | "",
776 | " if(responseJSON.articles.length){",
777 | " var article = responseJSON.articles[0];",
778 | "",
779 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
780 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
781 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
782 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
783 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
784 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
785 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
786 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
787 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
788 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
789 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
790 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
791 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
792 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
793 | " } else {",
794 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
795 | " }",
796 | "}",
797 | ""
798 | ]
799 | }
800 | }
801 | ],
802 | "request": {
803 | "method": "GET",
804 | "header": [
805 | {
806 | "key": "Content-Type",
807 | "value": "application/json"
808 | },
809 | {
810 | "key": "X-Requested-With",
811 | "value": "XMLHttpRequest"
812 | },
813 | {
814 | "key": "Authorization",
815 | "value": "Token {{token}}"
816 | }
817 | ],
818 | "body": {
819 | "mode": "raw",
820 | "raw": ""
821 | },
822 | "url": {
823 | "raw": "{{APIURL}}/articles",
824 | "host": [
825 | "{{APIURL}}"
826 | ],
827 | "path": [
828 | "articles"
829 | ]
830 | }
831 | },
832 | "response": []
833 | },
834 | {
835 | "name": "All Articles with auth",
836 | "event": [
837 | {
838 | "listen": "test",
839 | "script": {
840 | "type": "text/javascript",
841 | "exec": [
842 | "var is200Response = responseCode.code === 200;",
843 | "",
844 | "tests['Response code is 200 OK'] = is200Response;",
845 | "",
846 | "if(is200Response){",
847 | " var responseJSON = JSON.parse(responseBody);",
848 | "",
849 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
850 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
851 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
852 | "",
853 | " if(responseJSON.articles.length){",
854 | " var article = responseJSON.articles[0];",
855 | "",
856 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
857 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
858 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
859 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
860 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
861 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
862 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
863 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
864 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
865 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
866 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
867 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
868 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
869 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
870 | " } else {",
871 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
872 | " }",
873 | "}",
874 | ""
875 | ]
876 | }
877 | }
878 | ],
879 | "request": {
880 | "method": "GET",
881 | "header": [
882 | {
883 | "key": "Content-Type",
884 | "value": "application/json"
885 | },
886 | {
887 | "key": "X-Requested-With",
888 | "value": "XMLHttpRequest"
889 | },
890 | {
891 | "key": "Authorization",
892 | "value": "Token {{token}}"
893 | }
894 | ],
895 | "body": {
896 | "mode": "raw",
897 | "raw": ""
898 | },
899 | "url": {
900 | "raw": "{{APIURL}}/articles",
901 | "host": [
902 | "{{APIURL}}"
903 | ],
904 | "path": [
905 | "articles"
906 | ]
907 | }
908 | },
909 | "response": []
910 | },
911 | {
912 | "name": "Articles by Author",
913 | "event": [
914 | {
915 | "listen": "test",
916 | "script": {
917 | "type": "text/javascript",
918 | "exec": [
919 | "var is200Response = responseCode.code === 200;",
920 | "",
921 | "tests['Response code is 200 OK'] = is200Response;",
922 | "",
923 | "if(is200Response){",
924 | " var responseJSON = JSON.parse(responseBody);",
925 | "",
926 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
927 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
928 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
929 | "",
930 | " if(responseJSON.articles.length){",
931 | " var article = responseJSON.articles[0];",
932 | "",
933 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
934 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
935 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
936 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
937 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
938 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
939 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
940 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
941 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
942 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
943 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
944 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
945 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
946 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
947 | " } else {",
948 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
949 | " }",
950 | "}",
951 | ""
952 | ]
953 | }
954 | }
955 | ],
956 | "request": {
957 | "method": "GET",
958 | "header": [
959 | {
960 | "key": "Content-Type",
961 | "value": "application/json"
962 | },
963 | {
964 | "key": "X-Requested-With",
965 | "value": "XMLHttpRequest"
966 | },
967 | {
968 | "key": "Authorization",
969 | "value": "Token {{token}}"
970 | }
971 | ],
972 | "body": {
973 | "mode": "raw",
974 | "raw": ""
975 | },
976 | "url": {
977 | "raw": "{{APIURL}}/articles?author={{USERNAME}}",
978 | "host": [
979 | "{{APIURL}}"
980 | ],
981 | "path": [
982 | "articles"
983 | ],
984 | "query": [
985 | {
986 | "key": "author",
987 | "value": "{{USERNAME}}"
988 | }
989 | ]
990 | }
991 | },
992 | "response": []
993 | },
994 | {
995 | "name": "Articles by Author with auth",
996 | "event": [
997 | {
998 | "listen": "test",
999 | "script": {
1000 | "type": "text/javascript",
1001 | "exec": [
1002 | "var is200Response = responseCode.code === 200;",
1003 | "",
1004 | "tests['Response code is 200 OK'] = is200Response;",
1005 | "",
1006 | "if(is200Response){",
1007 | " var responseJSON = JSON.parse(responseBody);",
1008 | "",
1009 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
1010 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
1011 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
1012 | "",
1013 | " if(responseJSON.articles.length){",
1014 | " var article = responseJSON.articles[0];",
1015 | "",
1016 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1017 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1018 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1019 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1020 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1021 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1022 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1023 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1024 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1025 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1026 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1027 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1028 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1029 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1030 | " } else {",
1031 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
1032 | " }",
1033 | "}",
1034 | ""
1035 | ]
1036 | }
1037 | }
1038 | ],
1039 | "request": {
1040 | "method": "GET",
1041 | "header": [
1042 | {
1043 | "key": "Content-Type",
1044 | "value": "application/json"
1045 | },
1046 | {
1047 | "key": "X-Requested-With",
1048 | "value": "XMLHttpRequest"
1049 | },
1050 | {
1051 | "key": "Authorization",
1052 | "value": "Token {{token}}"
1053 | }
1054 | ],
1055 | "body": {
1056 | "mode": "raw",
1057 | "raw": ""
1058 | },
1059 | "url": {
1060 | "raw": "{{APIURL}}/articles?author={{USERNAME}}",
1061 | "host": [
1062 | "{{APIURL}}"
1063 | ],
1064 | "path": [
1065 | "articles"
1066 | ],
1067 | "query": [
1068 | {
1069 | "key": "author",
1070 | "value": "{{USERNAME}}"
1071 | }
1072 | ]
1073 | }
1074 | },
1075 | "response": []
1076 | },
1077 | {
1078 | "name": "Articles Favorited by Username",
1079 | "event": [
1080 | {
1081 | "listen": "test",
1082 | "script": {
1083 | "type": "text/javascript",
1084 | "exec": [
1085 | "var is200Response = responseCode.code === 200;",
1086 | "",
1087 | "tests['Response code is 200 OK'] = is200Response;",
1088 | "",
1089 | "if(is200Response){",
1090 | " var responseJSON = JSON.parse(responseBody);",
1091 | " ",
1092 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
1093 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
1094 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
1095 | "",
1096 | " if(responseJSON.articles.length){",
1097 | " var article = responseJSON.articles[0];",
1098 | "",
1099 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1100 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1101 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1102 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1103 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1104 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1105 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1106 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1107 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1108 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1109 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1110 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1111 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1112 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1113 | " } else {",
1114 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
1115 | " }",
1116 | "}",
1117 | ""
1118 | ]
1119 | }
1120 | }
1121 | ],
1122 | "request": {
1123 | "method": "GET",
1124 | "header": [
1125 | {
1126 | "key": "Content-Type",
1127 | "value": "application/json"
1128 | },
1129 | {
1130 | "key": "X-Requested-With",
1131 | "value": "XMLHttpRequest"
1132 | },
1133 | {
1134 | "key": "Authorization",
1135 | "value": "Token {{token}}"
1136 | }
1137 | ],
1138 | "body": {
1139 | "mode": "raw",
1140 | "raw": ""
1141 | },
1142 | "url": {
1143 | "raw": "{{APIURL}}/articles?favorited=jane",
1144 | "host": [
1145 | "{{APIURL}}"
1146 | ],
1147 | "path": [
1148 | "articles"
1149 | ],
1150 | "query": [
1151 | {
1152 | "key": "favorited",
1153 | "value": "jane"
1154 | }
1155 | ]
1156 | }
1157 | },
1158 | "response": []
1159 | },
1160 | {
1161 | "name": "Articles Favorited by Username with auth",
1162 | "event": [
1163 | {
1164 | "listen": "test",
1165 | "script": {
1166 | "type": "text/javascript",
1167 | "exec": [
1168 | "var is200Response = responseCode.code === 200;",
1169 | "",
1170 | "tests['Response code is 200 OK'] = is200Response;",
1171 | "",
1172 | "if(is200Response){",
1173 | " var responseJSON = JSON.parse(responseBody);",
1174 | " ",
1175 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
1176 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
1177 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
1178 | "",
1179 | " if(responseJSON.articles.length){",
1180 | " var article = responseJSON.articles[0];",
1181 | "",
1182 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1183 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1184 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1185 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1186 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1187 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1188 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1189 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1190 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1191 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1192 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1193 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1194 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1195 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1196 | " } else {",
1197 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
1198 | " }",
1199 | "}",
1200 | ""
1201 | ]
1202 | }
1203 | }
1204 | ],
1205 | "request": {
1206 | "method": "GET",
1207 | "header": [
1208 | {
1209 | "key": "Content-Type",
1210 | "value": "application/json"
1211 | },
1212 | {
1213 | "key": "X-Requested-With",
1214 | "value": "XMLHttpRequest"
1215 | },
1216 | {
1217 | "key": "Authorization",
1218 | "value": "Token {{token}}"
1219 | }
1220 | ],
1221 | "body": {
1222 | "mode": "raw",
1223 | "raw": ""
1224 | },
1225 | "url": {
1226 | "raw": "{{APIURL}}/articles?favorited=jane",
1227 | "host": [
1228 | "{{APIURL}}"
1229 | ],
1230 | "path": [
1231 | "articles"
1232 | ],
1233 | "query": [
1234 | {
1235 | "key": "favorited",
1236 | "value": "jane"
1237 | }
1238 | ]
1239 | }
1240 | },
1241 | "response": []
1242 | },
1243 | {
1244 | "name": "Single Article by slug",
1245 | "event": [
1246 | {
1247 | "listen": "test",
1248 | "script": {
1249 | "type": "text/javascript",
1250 | "exec": [
1251 | "var responseJSON = JSON.parse(responseBody);",
1252 | "",
1253 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
1254 | "",
1255 | "var article = responseJSON.article || {};",
1256 | "",
1257 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1258 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1259 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1260 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1261 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1262 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1263 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1264 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1265 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1266 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1267 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1268 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1269 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1270 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1271 | ""
1272 | ]
1273 | }
1274 | }
1275 | ],
1276 | "request": {
1277 | "method": "GET",
1278 | "header": [
1279 | {
1280 | "key": "Content-Type",
1281 | "value": "application/json"
1282 | },
1283 | {
1284 | "key": "X-Requested-With",
1285 | "value": "XMLHttpRequest"
1286 | },
1287 | {
1288 | "key": "Authorization",
1289 | "value": "Token {{token}}"
1290 | }
1291 | ],
1292 | "body": {
1293 | "mode": "raw",
1294 | "raw": ""
1295 | },
1296 | "url": {
1297 | "raw": "{{APIURL}}/articles/{{slug}}",
1298 | "host": [
1299 | "{{APIURL}}"
1300 | ],
1301 | "path": [
1302 | "articles",
1303 | "{{slug}}"
1304 | ]
1305 | }
1306 | },
1307 | "response": []
1308 | },
1309 | {
1310 | "name": "Articles by Tag",
1311 | "event": [
1312 | {
1313 | "listen": "test",
1314 | "script": {
1315 | "type": "text/javascript",
1316 | "exec": [
1317 | "var is200Response = responseCode.code === 200;",
1318 | "",
1319 | "tests['Response code is 200 OK'] = is200Response;",
1320 | "",
1321 | "if(is200Response){",
1322 | " var responseJSON = JSON.parse(responseBody);",
1323 | "",
1324 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
1325 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
1326 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
1327 | "",
1328 | " if(responseJSON.articles.length){",
1329 | " var article = responseJSON.articles[0];",
1330 | "",
1331 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1332 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1333 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1334 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1335 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1336 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1337 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1338 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1339 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1340 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1341 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1342 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1343 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1344 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1345 | " } else {",
1346 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
1347 | " }",
1348 | "}",
1349 | ""
1350 | ]
1351 | }
1352 | }
1353 | ],
1354 | "request": {
1355 | "method": "GET",
1356 | "header": [
1357 | {
1358 | "key": "Content-Type",
1359 | "value": "application/json"
1360 | },
1361 | {
1362 | "key": "X-Requested-With",
1363 | "value": "XMLHttpRequest"
1364 | },
1365 | {
1366 | "key": "Authorization",
1367 | "value": "Token {{token}}"
1368 | }
1369 | ],
1370 | "body": {
1371 | "mode": "raw",
1372 | "raw": ""
1373 | },
1374 | "url": {
1375 | "raw": "{{APIURL}}/articles?tag=dragons",
1376 | "host": [
1377 | "{{APIURL}}"
1378 | ],
1379 | "path": [
1380 | "articles"
1381 | ],
1382 | "query": [
1383 | {
1384 | "key": "tag",
1385 | "value": "dragons"
1386 | }
1387 | ]
1388 | }
1389 | },
1390 | "response": []
1391 | },
1392 | {
1393 | "name": "Update Article",
1394 | "event": [
1395 | {
1396 | "listen": "test",
1397 | "script": {
1398 | "type": "text/javascript",
1399 | "exec": [
1400 | "if (!(environment.isIntegrationTest)) {",
1401 | "var responseJSON = JSON.parse(responseBody);",
1402 | "",
1403 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
1404 | "",
1405 | "var article = responseJSON.article || {};",
1406 | "",
1407 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1408 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1409 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1410 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1411 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1412 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1413 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1414 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1415 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1416 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1417 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1418 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1419 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1420 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1421 | "}",
1422 | ""
1423 | ]
1424 | }
1425 | }
1426 | ],
1427 | "request": {
1428 | "method": "PUT",
1429 | "header": [
1430 | {
1431 | "key": "Content-Type",
1432 | "value": "application/json"
1433 | },
1434 | {
1435 | "key": "X-Requested-With",
1436 | "value": "XMLHttpRequest"
1437 | },
1438 | {
1439 | "key": "Authorization",
1440 | "value": "Token {{token}}"
1441 | }
1442 | ],
1443 | "body": {
1444 | "mode": "raw",
1445 | "raw": "{\"article\":{\"body\":\"With two hands\"}}"
1446 | },
1447 | "url": {
1448 | "raw": "{{APIURL}}/articles/{{slug}}",
1449 | "host": [
1450 | "{{APIURL}}"
1451 | ],
1452 | "path": [
1453 | "articles",
1454 | "{{slug}}"
1455 | ]
1456 | }
1457 | },
1458 | "response": []
1459 | },
1460 | {
1461 | "name": "Favorite Article",
1462 | "event": [
1463 | {
1464 | "listen": "test",
1465 | "script": {
1466 | "type": "text/javascript",
1467 | "exec": [
1468 | "var responseJSON = JSON.parse(responseBody);",
1469 | "",
1470 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
1471 | "",
1472 | "var article = responseJSON.article || {};",
1473 | "",
1474 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1475 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1476 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1477 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1478 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1479 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1480 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1481 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1482 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1483 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1484 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1485 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1486 | "tests[\"Article's 'favorited' property is true\"] = article.favorited === true;",
1487 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1488 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1489 | "tests[\"Article's 'favoritesCount' property is greater than 0\"] = article.favoritesCount > 0;",
1490 | ""
1491 | ]
1492 | }
1493 | }
1494 | ],
1495 | "request": {
1496 | "method": "POST",
1497 | "header": [
1498 | {
1499 | "key": "Content-Type",
1500 | "value": "application/json"
1501 | },
1502 | {
1503 | "key": "X-Requested-With",
1504 | "value": "XMLHttpRequest"
1505 | },
1506 | {
1507 | "key": "Authorization",
1508 | "value": "Token {{token}}"
1509 | }
1510 | ],
1511 | "body": {
1512 | "mode": "raw",
1513 | "raw": ""
1514 | },
1515 | "url": {
1516 | "raw": "{{APIURL}}/articles/{{slug}}/favorite",
1517 | "host": [
1518 | "{{APIURL}}"
1519 | ],
1520 | "path": [
1521 | "articles",
1522 | "{{slug}}",
1523 | "favorite"
1524 | ]
1525 | }
1526 | },
1527 | "response": []
1528 | },
1529 | {
1530 | "name": "Unfavorite Article",
1531 | "event": [
1532 | {
1533 | "listen": "test",
1534 | "script": {
1535 | "type": "text/javascript",
1536 | "exec": [
1537 | "var responseJSON = JSON.parse(responseBody);",
1538 | "",
1539 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
1540 | "",
1541 | "var article = responseJSON.article || {};",
1542 | "",
1543 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
1544 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
1545 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
1546 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
1547 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);",
1548 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
1549 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);",
1550 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
1551 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
1552 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
1553 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
1554 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
1555 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
1556 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
1557 | "tests[\"Article's \\\"favorited\\\" property is false\"] = article.favorited === false;",
1558 | ""
1559 | ]
1560 | }
1561 | }
1562 | ],
1563 | "request": {
1564 | "method": "DELETE",
1565 | "header": [
1566 | {
1567 | "key": "Content-Type",
1568 | "value": "application/json"
1569 | },
1570 | {
1571 | "key": "X-Requested-With",
1572 | "value": "XMLHttpRequest"
1573 | },
1574 | {
1575 | "key": "Authorization",
1576 | "value": "Token {{token}}"
1577 | }
1578 | ],
1579 | "body": {
1580 | "mode": "raw",
1581 | "raw": ""
1582 | },
1583 | "url": {
1584 | "raw": "{{APIURL}}/articles/{{slug}}/favorite",
1585 | "host": [
1586 | "{{APIURL}}"
1587 | ],
1588 | "path": [
1589 | "articles",
1590 | "{{slug}}",
1591 | "favorite"
1592 | ]
1593 | }
1594 | },
1595 | "response": []
1596 | },
1597 | {
1598 | "name": "Create Comment for Article",
1599 | "event": [
1600 | {
1601 | "listen": "test",
1602 | "script": {
1603 | "id": "9f90c364-cc68-4728-961a-85eb00197d7b",
1604 | "type": "text/javascript",
1605 | "exec": [
1606 | "var responseJSON = JSON.parse(responseBody);",
1607 | "",
1608 | "tests['Response contains \"comment\" property'] = responseJSON.hasOwnProperty('comment');",
1609 | "",
1610 | "var comment = responseJSON.comment || {};",
1611 | "",
1612 | "tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');",
1613 | "pm.globals.set('commentId', comment.id);",
1614 | "",
1615 | "tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');",
1616 | "tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');",
1617 | "tests['\"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.createdAt);",
1618 | "tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');",
1619 | "tests['\"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.updatedAt);",
1620 | "tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');",
1621 | ""
1622 | ]
1623 | }
1624 | }
1625 | ],
1626 | "request": {
1627 | "method": "POST",
1628 | "header": [
1629 | {
1630 | "key": "Content-Type",
1631 | "value": "application/json"
1632 | },
1633 | {
1634 | "key": "X-Requested-With",
1635 | "value": "XMLHttpRequest"
1636 | },
1637 | {
1638 | "key": "Authorization",
1639 | "value": "Token {{token}}"
1640 | }
1641 | ],
1642 | "body": {
1643 | "mode": "raw",
1644 | "raw": "{\"comment\":{\"body\":\"Thank you so much!\"}}"
1645 | },
1646 | "url": {
1647 | "raw": "{{APIURL}}/articles/{{slug}}/comments",
1648 | "host": [
1649 | "{{APIURL}}"
1650 | ],
1651 | "path": [
1652 | "articles",
1653 | "{{slug}}",
1654 | "comments"
1655 | ]
1656 | }
1657 | },
1658 | "response": []
1659 | },
1660 | {
1661 | "name": "All Comments for Article",
1662 | "event": [
1663 | {
1664 | "listen": "test",
1665 | "script": {
1666 | "type": "text/javascript",
1667 | "exec": [
1668 | "var is200Response = responseCode.code === 200",
1669 | "",
1670 | "tests['Response code is 200 OK'] = is200Response;",
1671 | "",
1672 | "if(is200Response){",
1673 | " var responseJSON = JSON.parse(responseBody);",
1674 | "",
1675 | " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');",
1676 | "",
1677 | " if(responseJSON.comments.length){",
1678 | " var comment = responseJSON.comments[0];",
1679 | "",
1680 | " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');",
1681 | " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');",
1682 | " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');",
1683 | " tests['\"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.createdAt);",
1684 | " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');",
1685 | " tests['\"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.updatedAt);",
1686 | " tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');",
1687 | " }",
1688 | "}",
1689 | ""
1690 | ]
1691 | }
1692 | }
1693 | ],
1694 | "request": {
1695 | "method": "GET",
1696 | "header": [
1697 | {
1698 | "key": "Content-Type",
1699 | "value": "application/json"
1700 | },
1701 | {
1702 | "key": "X-Requested-With",
1703 | "value": "XMLHttpRequest"
1704 | },
1705 | {
1706 | "key": "Authorization",
1707 | "value": "Token {{token}}"
1708 | }
1709 | ],
1710 | "body": {
1711 | "mode": "raw",
1712 | "raw": ""
1713 | },
1714 | "url": {
1715 | "raw": "{{APIURL}}/articles/{{slug}}/comments",
1716 | "host": [
1717 | "{{APIURL}}"
1718 | ],
1719 | "path": [
1720 | "articles",
1721 | "{{slug}}",
1722 | "comments"
1723 | ]
1724 | }
1725 | },
1726 | "response": []
1727 | },
1728 | {
1729 | "name": "Delete Comment for Article",
1730 | "request": {
1731 | "method": "DELETE",
1732 | "header": [
1733 | {
1734 | "key": "Content-Type",
1735 | "value": "application/json"
1736 | },
1737 | {
1738 | "key": "X-Requested-With",
1739 | "value": "XMLHttpRequest"
1740 | },
1741 | {
1742 | "key": "Authorization",
1743 | "value": "Token {{token}}"
1744 | }
1745 | ],
1746 | "body": {
1747 | "mode": "raw",
1748 | "raw": ""
1749 | },
1750 | "url": {
1751 | "raw": "{{APIURL}}/articles/{{slug}}/comments/{{commentId}}",
1752 | "host": [
1753 | "{{APIURL}}"
1754 | ],
1755 | "path": [
1756 | "articles",
1757 | "{{slug}}",
1758 | "comments",
1759 | "{{commentId}}"
1760 | ]
1761 | }
1762 | },
1763 | "response": []
1764 | },
1765 | {
1766 | "name": "Delete Article",
1767 | "request": {
1768 | "method": "DELETE",
1769 | "header": [
1770 | {
1771 | "key": "Content-Type",
1772 | "value": "application/json"
1773 | },
1774 | {
1775 | "key": "X-Requested-With",
1776 | "value": "XMLHttpRequest"
1777 | },
1778 | {
1779 | "key": "Authorization",
1780 | "value": "Token {{token}}"
1781 | }
1782 | ],
1783 | "body": {
1784 | "mode": "raw",
1785 | "raw": ""
1786 | },
1787 | "url": {
1788 | "raw": "{{APIURL}}/articles/{{slug}}",
1789 | "host": [
1790 | "{{APIURL}}"
1791 | ],
1792 | "path": [
1793 | "articles",
1794 | "{{slug}}"
1795 | ]
1796 | }
1797 | },
1798 | "response": []
1799 | }
1800 | ],
1801 | "event": [
1802 | {
1803 | "listen": "prerequest",
1804 | "script": {
1805 | "id": "67853a4a-e972-4573-a295-dad12a46a9d7",
1806 | "type": "text/javascript",
1807 | "exec": [
1808 | ""
1809 | ]
1810 | }
1811 | },
1812 | {
1813 | "listen": "test",
1814 | "script": {
1815 | "id": "3057f989-15e4-484e-b8fa-a041043d0ac0",
1816 | "type": "text/javascript",
1817 | "exec": [
1818 | ""
1819 | ]
1820 | }
1821 | }
1822 | ]
1823 | },
1824 | {
1825 | "name": "Profiles",
1826 | "item": [
1827 | {
1828 | "name": "Register Celeb",
1829 | "event": [
1830 | {
1831 | "listen": "test",
1832 | "script": {
1833 | "type": "text/javascript",
1834 | "exec": [
1835 | "if (!(environment.isIntegrationTest)) {",
1836 | "var responseJSON = JSON.parse(responseBody);",
1837 | "",
1838 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
1839 | "",
1840 | "var user = responseJSON.user || {};",
1841 | "",
1842 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
1843 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
1844 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
1845 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
1846 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
1847 | "}",
1848 | ""
1849 | ]
1850 | }
1851 | }
1852 | ],
1853 | "request": {
1854 | "method": "POST",
1855 | "header": [
1856 | {
1857 | "key": "Content-Type",
1858 | "value": "application/json"
1859 | },
1860 | {
1861 | "key": "X-Requested-With",
1862 | "value": "XMLHttpRequest"
1863 | }
1864 | ],
1865 | "body": {
1866 | "mode": "raw",
1867 | "raw": "{\"user\":{\"email\":\"celeb_{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"celeb_{{USERNAME}}\"}}"
1868 | },
1869 | "url": {
1870 | "raw": "{{APIURL}}/users",
1871 | "host": [
1872 | "{{APIURL}}"
1873 | ],
1874 | "path": [
1875 | "users"
1876 | ]
1877 | }
1878 | },
1879 | "response": []
1880 | },
1881 | {
1882 | "name": "Profile",
1883 | "event": [
1884 | {
1885 | "listen": "test",
1886 | "script": {
1887 | "type": "text/javascript",
1888 | "exec": [
1889 | "if (!(environment.isIntegrationTest)) {",
1890 | "var is200Response = responseCode.code === 200;",
1891 | "",
1892 | "tests['Response code is 200 OK'] = is200Response;",
1893 | "",
1894 | "if(is200Response){",
1895 | " var responseJSON = JSON.parse(responseBody);",
1896 | "",
1897 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');",
1898 | " ",
1899 | " var profile = responseJSON.profile || {};",
1900 | " ",
1901 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');",
1902 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');",
1903 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');",
1904 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');",
1905 | "}",
1906 | "}",
1907 | ""
1908 | ]
1909 | }
1910 | }
1911 | ],
1912 | "request": {
1913 | "method": "GET",
1914 | "header": [
1915 | {
1916 | "key": "Content-Type",
1917 | "value": "application/json"
1918 | },
1919 | {
1920 | "key": "X-Requested-With",
1921 | "value": "XMLHttpRequest"
1922 | },
1923 | {
1924 | "key": "Authorization",
1925 | "value": "Token {{token}}"
1926 | }
1927 | ],
1928 | "body": {
1929 | "mode": "raw",
1930 | "raw": ""
1931 | },
1932 | "url": {
1933 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}",
1934 | "host": [
1935 | "{{APIURL}}"
1936 | ],
1937 | "path": [
1938 | "profiles",
1939 | "celeb_{{USERNAME}}"
1940 | ]
1941 | }
1942 | },
1943 | "response": []
1944 | },
1945 | {
1946 | "name": "Follow Profile",
1947 | "event": [
1948 | {
1949 | "listen": "test",
1950 | "script": {
1951 | "type": "text/javascript",
1952 | "exec": [
1953 | "if (!(environment.isIntegrationTest)) {",
1954 | "var is200Response = responseCode.code === 200;",
1955 | "",
1956 | "tests['Response code is 200 OK'] = is200Response;",
1957 | "",
1958 | "if(is200Response){",
1959 | " var responseJSON = JSON.parse(responseBody);",
1960 | "",
1961 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');",
1962 | " ",
1963 | " var profile = responseJSON.profile || {};",
1964 | " ",
1965 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');",
1966 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');",
1967 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');",
1968 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');",
1969 | " tests['Profile\\'s \"following\" property is true'] = profile.following === true;",
1970 | "}",
1971 | "}",
1972 | ""
1973 | ]
1974 | }
1975 | }
1976 | ],
1977 | "request": {
1978 | "method": "POST",
1979 | "header": [
1980 | {
1981 | "key": "Content-Type",
1982 | "value": "application/json"
1983 | },
1984 | {
1985 | "key": "X-Requested-With",
1986 | "value": "XMLHttpRequest"
1987 | },
1988 | {
1989 | "key": "Authorization",
1990 | "value": "Token {{token}}"
1991 | }
1992 | ],
1993 | "body": {
1994 | "mode": "raw",
1995 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}"
1996 | },
1997 | "url": {
1998 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow",
1999 | "host": [
2000 | "{{APIURL}}"
2001 | ],
2002 | "path": [
2003 | "profiles",
2004 | "celeb_{{USERNAME}}",
2005 | "follow"
2006 | ]
2007 | }
2008 | },
2009 | "response": []
2010 | },
2011 | {
2012 | "name": "Unfollow Profile",
2013 | "event": [
2014 | {
2015 | "listen": "test",
2016 | "script": {
2017 | "type": "text/javascript",
2018 | "exec": [
2019 | "if (!(environment.isIntegrationTest)) {",
2020 | "var is200Response = responseCode.code === 200;",
2021 | "",
2022 | "tests['Response code is 200 OK'] = is200Response;",
2023 | "",
2024 | "if(is200Response){",
2025 | " var responseJSON = JSON.parse(responseBody);",
2026 | "",
2027 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');",
2028 | " ",
2029 | " var profile = responseJSON.profile || {};",
2030 | " ",
2031 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');",
2032 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');",
2033 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');",
2034 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');",
2035 | " tests['Profile\\'s \"following\" property is false'] = profile.following === false;",
2036 | "}",
2037 | "}",
2038 | ""
2039 | ]
2040 | }
2041 | }
2042 | ],
2043 | "request": {
2044 | "method": "DELETE",
2045 | "header": [
2046 | {
2047 | "key": "Content-Type",
2048 | "value": "application/json"
2049 | },
2050 | {
2051 | "key": "X-Requested-With",
2052 | "value": "XMLHttpRequest"
2053 | },
2054 | {
2055 | "key": "Authorization",
2056 | "value": "Token {{token}}"
2057 | }
2058 | ],
2059 | "body": {
2060 | "mode": "raw",
2061 | "raw": ""
2062 | },
2063 | "url": {
2064 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow",
2065 | "host": [
2066 | "{{APIURL}}"
2067 | ],
2068 | "path": [
2069 | "profiles",
2070 | "celeb_{{USERNAME}}",
2071 | "follow"
2072 | ]
2073 | }
2074 | },
2075 | "response": []
2076 | }
2077 | ]
2078 | },
2079 | {
2080 | "name": "Tags",
2081 | "item": [
2082 | {
2083 | "name": "All Tags",
2084 | "event": [
2085 | {
2086 | "listen": "test",
2087 | "script": {
2088 | "type": "text/javascript",
2089 | "exec": [
2090 | "var is200Response = responseCode.code === 200;",
2091 | "",
2092 | "tests['Response code is 200 OK'] = is200Response;",
2093 | "",
2094 | "if(is200Response){",
2095 | " var responseJSON = JSON.parse(responseBody);",
2096 | " ",
2097 | " tests['Response contains \"tags\" property'] = responseJSON.hasOwnProperty('tags');",
2098 | " tests['\"tags\" property returned as array'] = Array.isArray(responseJSON.tags);",
2099 | "}",
2100 | ""
2101 | ]
2102 | }
2103 | }
2104 | ],
2105 | "request": {
2106 | "method": "GET",
2107 | "header": [
2108 | {
2109 | "key": "Content-Type",
2110 | "value": "application/json"
2111 | },
2112 | {
2113 | "key": "X-Requested-With",
2114 | "value": "XMLHttpRequest"
2115 | }
2116 | ],
2117 | "body": {
2118 | "mode": "raw",
2119 | "raw": ""
2120 | },
2121 | "url": {
2122 | "raw": "{{APIURL}}/tags",
2123 | "host": [
2124 | "{{APIURL}}"
2125 | ],
2126 | "path": [
2127 | "tags"
2128 | ]
2129 | }
2130 | },
2131 | "response": []
2132 | }
2133 | ]
2134 | }
2135 | ]
2136 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 
2 |
3 | > ### Neo4j & Typescript (using Nest.js) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
4 |
5 |
6 | This codebase was created to demonstrate a fully fledged fullstack application built with a **Neo4j** database backed [Nest.js](https://nestjs.com) application including CRUD operations, authentication, routing, pagination, and more.
7 |
8 | We've gone to great lengths to adhere to the [Neo4j](https://neo4j.com) and [Nest.js](https://nestjs.com) community styleguides & best practices.
9 |
10 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.
11 |
12 |
13 | # How it works
14 |
15 | Neo4j is a [Graph Database](https://neo4j.com/developer/graph-database/), a database designed to hold the connections between data (known as relationships) as important as the data itself. A Neo4j database consists of Nodes connected together with Relationships. Both nodes and relationships can contain one or more properties, which are key/value pairs.
16 |
17 | For more information on how Neo4j compares to other databases, you can check the following links:
18 |
19 | * [RDBMS to Graph](https://neo4j.com/developer/graph-db-vs-rdbms/)
20 | * [NoSQL to Graph](https://neo4j.com/developer/graph-db-vs-nosql/)
21 |
22 |
23 | ## Data Model
24 |
25 | 
26 |
27 | The data model diagram has been created with [Arrows](http://www.apcjones.com/arrows/). You can edit the model by clicking the **Export Markup** button in Arrows, copying the contents of [arrows.html](model/arrows.html) into the text box and clicking **Save** at the bottom of the modal.
28 |
29 | ## Dependencies
30 |
31 | * [nest-neo4j](https://github.com/adam-cowley/nest-neo4j) - A module that provides functionality for interacting with a Neo4j Database inside a Nest.js application.
32 | * **Authentication** is provided by the `passport`, `passport-jwt` and `passport-local` packages. For more information on how this was implemented, check out the [Authenticating Users in Nest.js with Neo4j ](https://www.youtube.com/watch?v=Y7125-Tb2jE&list=PL9Hl4pk2FsvX-Y5-phtnqY4hJaWeocOkq&index=3) video on the [Neo4j Youtube Channel](https://youtube.com/neo4j).
33 |
34 | ## Modules, Controllers, Services
35 |
36 | The application contains two modules. Modules are a way to group functionality (think domains and subdomains in DDD) and a convenient way to register functionality with the main app. These modules are registered in the [AppModule](./src/app.module.ts).
37 |
38 | * [**user**](/src/user) - This module provides functionality based around User nodes. This includes user profiles, follow/unfollow functionality and all authentication functionality.
39 | * [**article**](/src/article) - All functionality based around Article and Tag nodes
40 |
41 |
42 | ## Validation Errors
43 |
44 | Validation errors are returned with the HTTP 400 Bad Request. The [UnprocessibleEntityValidationPipe](./src/pipes/unprocessible-entity-validation.pipe.ts) extends Nest.js's out-of-the-box `ValidationPipe`, providing an `exceptionFactory` function that instead returns an `UnprocessableEntityException` error containing the error messages required by the UI.
45 |
46 |
47 | # Further Reading
48 |
49 | This example was built as part of a series of Live Streams on the [Neo4j Twitch Channel](https://twitch.tv/neo4j_). You can watch the videos back on the [Building Applications with Neo4j and Typescript playlist](https://www.youtube.com/c/neo4j/playlists) on the [Neo4j Youtube Channel].
50 |
51 |
52 |
53 | # Getting started
54 |
55 | ## Installation
56 |
57 | ```bash
58 | $ npm install
59 | ```
60 |
61 | ## Running the app
62 |
63 | ```bash
64 | # development
65 | $ npm run start
66 |
67 | # watch mode
68 | $ npm run start:dev
69 |
70 | # production mode
71 | $ npm run start:prod
72 | ```
73 |
74 | ## Test
75 |
76 | ```bash
77 | # unit tests
78 | $ npm run test
79 |
80 | # e2e tests
81 | $ npm run test:e2e
82 |
83 | # test coverage
84 | $ npm run test:cov
85 | ```
86 |
87 |
88 | # Questions, Comments, Support
89 |
90 | If you have any questions or comments, please feel free to reach out on the [Neo4j Community Forum](https://community.neo4j.com) or create an Issue. If you spot any bugs or missing features, feel free to submit a Pull Request.
91 |
--------------------------------------------------------------------------------
/model/arrows.html:
--------------------------------------------------------------------------------
1 |
2 | -
3 | User
- id
- uuid
- username
- String
- password
- String
- image
- String
- bio
- String
- createdAt
- DateTime
- updatedAt
- DateTime
4 | -
5 | Article
- id
- uuid
- title
- String
- body
- String
- description
- String
- createdAt
- DateTime
- updatedAt
- DateTime
6 | -
7 | Tag
- id
- uuid
- slug
- String
- name
- String
- createdAt
- DateTime
- updatedAt
- DateTime
8 | -
9 | Comment
- id
- uuid
- body
- String
- createdAt
- DateTime
- updatedAt
- DateTime
10 | -
11 | POSTED
12 |
13 | -
14 | HAS_TAG
15 |
16 | -
17 | COMMENTED
18 |
19 | -
20 | FOR
21 |
22 |
--------------------------------------------------------------------------------
/model/arrows.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "collection": "@nestjs/schematics",
3 | "sourceRoot": "src"
4 | }
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nestjs-neo4j-realworld-example-app",
3 | "version": "0.0.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "prebuild": "rimraf dist",
10 | "build": "nest build",
11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12 | "start": "nest start",
13 | "start:dev": "nest start --watch",
14 | "start:debug": "nest start --debug --watch",
15 | "start:prod": "node dist/main",
16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
17 | "test": "jest",
18 | "test:watch": "jest --watch",
19 | "test:cov": "jest --coverage",
20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
21 | "test:e2e": "jest --config ./test/jest-e2e.json --watch --detectOpenHandles",
22 | "test:e2ecov": "jest --detectOpenHandles --coverage --config ./test/jest-e2e.json "
23 | },
24 | "dependencies": {
25 | "@nestjs/common": "^7.0.0",
26 | "@nestjs/config": "^0.5.0",
27 | "@nestjs/core": "^7.0.0",
28 | "@nestjs/jwt": "^7.1.0",
29 | "@nestjs/passport": "^7.1.0",
30 | "@nestjs/platform-express": "^7.0.0",
31 | "bcrypt": "^5.0.0",
32 | "class-transformer": "^0.3.1",
33 | "class-validator": "^0.12.2",
34 | "neo4j-driver": "^4.1.1",
35 | "nest-neo4j": "^0.1.3",
36 | "passport": "^0.4.1",
37 | "passport-jwt": "^4.0.0",
38 | "passport-local": "^1.0.0",
39 | "reflect-metadata": "^0.1.13",
40 | "rimraf": "^3.0.2",
41 | "rxjs": "^6.5.4"
42 | },
43 | "devDependencies": {
44 | "@nestjs/cli": "^7.0.0",
45 | "@nestjs/schematics": "^7.0.0",
46 | "@nestjs/testing": "^7.0.0",
47 | "@types/express": "^4.17.3",
48 | "@types/jest": "25.2.3",
49 | "@types/node": "^13.9.1",
50 | "@types/passport-jwt": "^3.0.3",
51 | "@types/passport-local": "^1.0.33",
52 | "@types/supertest": "^2.0.8",
53 | "@typescript-eslint/eslint-plugin": "3.0.2",
54 | "@typescript-eslint/parser": "3.0.2",
55 | "eslint": "7.1.0",
56 | "eslint-config-prettier": "^6.10.0",
57 | "eslint-plugin-import": "^2.20.1",
58 | "jest": "26.0.1",
59 | "prettier": "^1.19.1",
60 | "supertest": "^4.0.2",
61 | "ts-jest": "26.1.0",
62 | "ts-loader": "^6.2.1",
63 | "ts-node": "^8.6.2",
64 | "tsconfig-paths": "^3.9.0",
65 | "typescript": "^3.7.4"
66 | },
67 | "jest": {
68 | "moduleFileExtensions": [
69 | "js",
70 | "json",
71 | "ts"
72 | ],
73 | "rootDir": "src",
74 | "testRegex": ".spec.ts$",
75 | "transform": {
76 | "^.+\\.(t|j)s$": "ts-jest"
77 | },
78 | "coverageDirectory": "../coverage",
79 | "testEnvironment": "node"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/project-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/neo4j-examples/nestjs-neo4j-realworld-example/bc5e5bf37f76c4b313d87a72692cde5d43e53ef8/project-logo.png
--------------------------------------------------------------------------------
/src/app.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { AppController } from './app.controller';
3 | import { AppService } from './app.service';
4 |
5 | describe('AppController', () => {
6 | let appController: AppController;
7 |
8 | beforeEach(async () => {
9 | const app: TestingModule = await Test.createTestingModule({
10 | controllers: [AppController],
11 | providers: [AppService],
12 | }).compile();
13 |
14 | appController = app.get(AppController);
15 | });
16 |
17 | describe('root', () => {
18 | it('should return "Hello World!"', () => {
19 | expect(appController.getHello()).toBe('Hello World!');
20 | });
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, UseGuards, Request } from '@nestjs/common';
2 | import { JwtAuthGuard } from './user/auth/jwt.auth-guard';
3 | import { AuthService } from './user/auth/auth.service';
4 | import { JwtService } from '@nestjs/jwt';
5 |
6 | @Controller()
7 | export class AppController {
8 |
9 | }
10 |
--------------------------------------------------------------------------------
/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module, Logger } from '@nestjs/common';
2 | import { ConfigModule, ConfigService } from '@nestjs/config';
3 | import { Neo4jModule, Neo4jConfig } from 'nest-neo4j';
4 | import { AppController } from './app.controller';
5 | import { AppService } from './app.service';
6 | import { UserModule } from './user/user.module';
7 | import { ArticleModule } from './article/article.module';
8 |
9 | @Module({
10 | imports: [
11 | ConfigModule.forRoot({ isGlobal: true }),
12 | Neo4jModule.forRootAsync({
13 | imports: [ ConfigModule ],
14 | inject: [ ConfigService ],
15 | useFactory: (configService: ConfigService): Neo4jConfig => ({
16 | scheme: configService.get('NEO4J_SCHEME'),
17 | host: configService.get('NEO4J_HOST'),
18 | port: configService.get('NEO4J_PORT'),
19 | username: configService.get('NEO4J_USERNAME'),
20 | password: configService.get('NEO4J_PASSWORD'),
21 | database: configService.get('NEO4J_DATABASE'),
22 | })
23 | }),
24 | UserModule,
25 | ArticleModule,
26 | ],
27 | providers: [AppService],
28 | controllers: [AppController],
29 | exports: []
30 | })
31 | export class AppModule {}
32 |
--------------------------------------------------------------------------------
/src/app.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 |
3 | @Injectable()
4 | export class AppService {
5 | getHello(): string {
6 | return 'Hello World!';
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/article/article.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { ArticleController } from './article.controller';
3 |
4 | describe('Article Controller', () => {
5 | let controller: ArticleController;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | controllers: [ArticleController],
10 | }).compile();
11 |
12 | controller = module.get(ArticleController);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(controller).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/article/article.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Post, Body, UseGuards, UseInterceptors, Param, NotFoundException, Put, Delete, BadRequestException } from '@nestjs/common';
2 | import { CreateArticleDto } from './dto/create-article.dto';
3 | import { ArticleService } from './article.service';
4 | import { JwtAuthGuard } from '../user/auth/jwt.auth-guard';
5 | import { Neo4jTypeInterceptor } from 'nest-neo4j/dist';
6 | import { UpdateArticleDto } from './dto/update-article.dto';
7 | import { CreateCommentDto } from './dto/create-comment.dto';
8 | import { JwtModule } from '@nestjs/jwt';
9 |
10 | @UseInterceptors(Neo4jTypeInterceptor)
11 | @Controller('articles')
12 | export class ArticleController {
13 |
14 | constructor(private readonly articleService: ArticleService) {}
15 |
16 | @UseGuards(JwtAuthGuard.optional())
17 | @Get()
18 | getList() {
19 | return this.articleService.list()
20 | }
21 |
22 | @UseGuards(JwtAuthGuard)
23 | @Post()
24 | async postList(@Body() createArticleDto: CreateArticleDto) {
25 | const article = await this.articleService.create(
26 | createArticleDto.article.title,
27 | createArticleDto.article.description,
28 | createArticleDto.article.body,
29 | createArticleDto.article.tagList
30 | )
31 |
32 | return {
33 | article: article.toJson()
34 | }
35 | }
36 |
37 | @UseGuards(JwtAuthGuard)
38 | @Get('/feed')
39 | async getFeed() {
40 | return this.articleService.getFeed()
41 | }
42 |
43 | @UseGuards(JwtAuthGuard.optional())
44 | @Get('/:slug')
45 | async getIndex(@Param('slug') slug: string) {
46 | const article = await this.articleService.find(slug)
47 |
48 | if ( !article ) throw new NotFoundException()
49 |
50 | return {
51 | article: article.toJson()
52 | }
53 | }
54 |
55 | @UseGuards(JwtAuthGuard)
56 | @Put('/:slug')
57 | async putIndex(@Param('slug') slug: string, @Body() updateArticleDto: UpdateArticleDto) {
58 | const article = await this.articleService.update(slug, updateArticleDto.article)
59 |
60 | if ( !article ) throw new NotFoundException()
61 |
62 | return {
63 | article: article.toJson()
64 | }
65 | }
66 |
67 | @UseGuards(JwtAuthGuard)
68 | @Put('/:slug')
69 | async deleteIndex(@Param('slug') slug: string) {
70 | const article = await this.articleService.delete(slug)
71 |
72 | if ( !article ) throw new NotFoundException()
73 |
74 | return 'OK'
75 | }
76 |
77 | @UseGuards(JwtAuthGuard)
78 | @Post('/:slug/favorite')
79 | async postFavorite(@Param('slug') slug: string) {
80 | const article = await this.articleService.favorite(slug)
81 |
82 | if ( !article ) throw new NotFoundException()
83 |
84 | return {
85 | article: article.toJson()
86 | }
87 | }
88 |
89 | @UseGuards(JwtAuthGuard)
90 | @Delete('/:slug/favorite')
91 | async deleteFavorite(@Param('slug') slug: string) {
92 | const article = await this.articleService.unfavorite(slug)
93 |
94 | if ( !article ) throw new NotFoundException()
95 |
96 | return {
97 | article: article.toJson()
98 | }
99 | }
100 |
101 | @UseGuards(JwtAuthGuard)
102 | @Post('/:slug/comments')
103 | async postComments(@Param('slug') slug: string, @Body() createCommentDto: CreateCommentDto) {
104 | const comment = await this.articleService.comment(slug, createCommentDto.comment.body)
105 |
106 | if ( !comment ) throw new NotFoundException()
107 |
108 | return {
109 | comment: comment.toJson()
110 | }
111 | }
112 |
113 | @Get('/:slug/comments')
114 | async getComments(@Param('slug') slug: string) {
115 | const comments = await this.articleService.getComments(slug)
116 |
117 | return {
118 | comments: comments.map(comment => comment.toJson()),
119 | }
120 | }
121 |
122 | @UseGuards(JwtAuthGuard)
123 | @Delete('/:slug/comments/:commentId')
124 | async deleteComments(@Param('slug') slug: string, @Param('commentId') commentId: string) {
125 | const outcome = await this.articleService.deleteComment(slug, commentId)
126 |
127 | if ( !outcome ) throw new NotFoundException()
128 |
129 | return 'OK'
130 | }
131 |
132 | @UseGuards(JwtAuthGuard)
133 | @Delete('/:slug')
134 | async deleteComment(@Param('slug') slug: string) {
135 | const comment = await this.articleService.delete(slug)
136 |
137 | if ( !comment ) throw new NotFoundException()
138 |
139 | return 'OK'
140 | }
141 |
142 | }
143 |
--------------------------------------------------------------------------------
/src/article/article.module.ts:
--------------------------------------------------------------------------------
1 | import { Module, OnModuleInit, LoggerService, Logger, } from '@nestjs/common';
2 | import { ArticleController } from './article.controller';
3 | import { ArticleService } from './article.service';
4 | import { TagsController } from './tags/tags.controller';
5 | import { TagService } from './tag/tag.service';
6 | import { Neo4jService } from 'nest-neo4j/dist';
7 |
8 | @Module({
9 | controllers: [ArticleController, TagsController],
10 | providers: [ArticleService, TagService]
11 | })
12 | export class ArticleModule implements OnModuleInit {
13 |
14 | constructor(private readonly neo4jService: Neo4jService) {}
15 |
16 | async onModuleInit() {
17 | await this.neo4jService.write('CREATE CONSTRAINT ON (a:Article) ASSERT a.id IS UNIQUE').catch(() => {})
18 | await this.neo4jService.write('CREATE CONSTRAINT ON (a:Article) ASSERT a.slug IS UNIQUE').catch(() => {})
19 | await this.neo4jService.write('CREATE CONSTRAINT ON (t:Tag) ASSERT t.id IS UNIQUE').catch(() => {})
20 | await this.neo4jService.write('CREATE CONSTRAINT ON (t:Tag) ASSERT t.name IS UNIQUE').catch(() => {})
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/article/article.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { ArticleService } from './article.service';
3 | import { Neo4jModule, Neo4jService } from 'nest-neo4j/dist';
4 | import { User } from '../user/entity/user.entity';
5 |
6 | import { int } from 'neo4j-driver'
7 | import { Node } from 'neo4j-driver/lib/graph-types'
8 | import { Result } from 'neo4j-driver/lib/result'
9 |
10 | jest.mock('neo4j-driver/lib/driver')
11 |
12 | import { mockNode, mockResult } from 'nest-neo4j/dist/test'
13 |
14 | describe('ArticleService', () => {
15 | let service: ArticleService;
16 | let neo4jService: Neo4jService
17 |
18 | beforeEach(async () => {
19 | const module: TestingModule = await Test.createTestingModule({
20 | imports: [
21 | Neo4jModule.forRoot({
22 | scheme: 'neo4j', host: 'localhost', port: 7687, username: 'neo4j', password: 'neox'
23 | })
24 | ],
25 | providers: [ArticleService],
26 | }).compile();
27 |
28 | service = await module.resolve(ArticleService);
29 | neo4jService = module.get(Neo4jService)
30 | });
31 |
32 | describe('::create()', () => {
33 | it('should create a new article', async () => {
34 | expect(service).toBeDefined();
35 | expect(neo4jService).toBeDefined();
36 |
37 | const data = {
38 | title: 'title',
39 | description: 'description',
40 | body: 'body',
41 | tagList: ['tag1', 'tag2']
42 | }
43 | const favoritesCount = 100
44 | const favorited = false
45 |
46 | // Assign user to request
47 | const user = new User( mockNode('User', { id: 'test-user' } ) )
48 | Object.defineProperty(service, 'request', { value: { user } })
49 |
50 | // Mock the response from neo4jService.write
51 | const write = jest.spyOn(neo4jService, 'write')
52 | .mockResolvedValue(
53 | mockResult([
54 | {
55 | u: user,
56 | a: mockNode('Article', { ...data, id: 'test-article-1' }),
57 | tagList: data.tagList.map(name => mockNode('Tag', { name })),
58 | favoritesCount,
59 | favorited,
60 | },
61 | ])
62 |
63 |
64 |
65 | // {
66 | // records: [
67 | // {
68 | // keys: [
69 | // 'u', 'a', 'tagList', 'favorited', 'favoritesCount'
70 | // ],
71 | // get: key => {
72 | // switch (key) {
73 | // case 'a':
74 | // // If requesting 'a', return a `Node` with the data
75 | // // passed to the `create` method
76 | // return new Node( int(100), ['Article'], { ...data, id: 'test-article-1' })
77 | // case 'tagList':
78 | // // If 'tagList' return an array of Nodes with a
79 | // // property to represent the name
80 | // return data.tagList.map((name, index) => new Node ( int(200 + index), 'Tag', { name }))
81 | // case 'favoritesCount':
82 | // // If favouritesCount then return a random number
83 | // return 100;
84 | // case 'favorited':
85 | // // If favorited, return a boolean
86 | // return false;
87 | // }
88 |
89 | // return null
90 | // }
91 | // }
92 | // ]
93 | )
94 |
95 |
96 | const article = await service.create(data.title, data.description, data.body, data.tagList)
97 |
98 | const json = article.toJson()
99 |
100 | expect(json).toEqual({
101 | ...data,
102 | author: user.toJson(),
103 | id: 'test-article-1',
104 | favorited,
105 | favoritesCount,
106 | })
107 |
108 | })
109 | })
110 |
111 | // it('should be defined', () => {
112 | // expect(service).toBeDefined();
113 | // });
114 | });
115 |
--------------------------------------------------------------------------------
/src/article/article.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Scope, Inject } from '@nestjs/common';
2 | import { User } from '../user/entity/user.entity';
3 | import { Article } from './entity/article.entity';
4 | import { Neo4jService } from 'nest-neo4j/dist';
5 | import { REQUEST } from '@nestjs/core';
6 | import { Request } from 'express';
7 | import { Comment } from './entity/comment.entity';
8 | import { Tag } from './entity/tag.entity';
9 |
10 | type ArticleResponse = {
11 | articlesCount: number;
12 | articles: Record[]
13 | }
14 |
15 | @Injectable({ scope: Scope.REQUEST })
16 | export class ArticleService {
17 |
18 | constructor(
19 | @Inject(REQUEST) private readonly request: Request,
20 | private readonly neo4jService: Neo4jService
21 | ) {}
22 |
23 | create(title: string, description: string, body: string, tagList: string[]): Promise {
24 | return this.neo4jService.write(`
25 | MATCH (u:User {id: $userId})
26 |
27 | WITH u, randomUUID() AS uuid
28 |
29 | CREATE (a:Article {
30 | id: uuid,
31 | createdAt: datetime(),
32 | updatedAt: datetime()
33 | }) SET a += $article, a.slug = apoc.text.slug($article.title +' '+ uuid)
34 |
35 | CREATE (u)-[:POSTED]->(a)
36 |
37 | FOREACH ( name IN $tagList |
38 | MERGE (t:Tag {name: name})
39 | ON CREATE SET t.id = randomUUID(), t.slug = apoc.text.slug(name)
40 |
41 | MERGE (a)-[:HAS_TAG]->(t)
42 | )
43 |
44 | RETURN u,
45 | a,
46 | [ (a)-[:HAS_TAG]->(t) | t ] AS tagList,
47 | exists((a)<-[:FAVORITED]-(u)) AS favorited,
48 | size((a)<-[:FAVORITED]-()) AS favoritesCount
49 | `, {
50 | userId: ( this.request.user).getId(),
51 | article: { title, description, body },
52 | tagList,
53 | })
54 | .then(res => {
55 | const row = res.records[0]
56 |
57 | return new Article(
58 | row.get('a'),
59 | this.request.user,
60 | row.get('tagList').map(tag => new Tag(tag)),
61 | row.get('favoritesCount'),
62 | row.get('favorited')
63 | )
64 | })
65 | }
66 |
67 | list(): Promise {
68 | const skip = this.neo4jService.int( parseInt( this.request.query.offset) || 0)
69 | const limit = this.neo4jService.int( parseInt( this.request.query.limit) || 10)
70 |
71 | const params: Record = {
72 | userId: this.request.user ? ( this.request.user).getId() : null,
73 | skip, limit
74 | }
75 |
76 | const where = [];
77 |
78 | if ( this.request.query.author ) {
79 | where.push( `(a)<-[:POSTED]-({username: $author})` )
80 | params.author = this.request.query.author
81 | }
82 |
83 | if ( this.request.query.favorited ) {
84 | where.push( `(a)<-[:FAVORITED]-({username: $favorited})` )
85 | params.favorited = this.request.query.favorited
86 | }
87 |
88 | if ( this.request.query.tag ) {
89 | where.push( ` ALL (tag in $tags WHERE (a)-[:HAS_TAG]->({name: tag})) ` )
90 | params.tags = ( this.request.query.tag).split(',')
91 | }
92 |
93 | return this.neo4jService.read(`
94 | MATCH (a:Article)
95 | ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
96 |
97 | WITH count(a) AS articlesCount, collect(a) AS articles
98 |
99 | UNWIND articles AS a
100 |
101 | WITH articlesCount, a
102 | ORDER BY a.createdAt DESC
103 | SKIP $skip LIMIT $limit
104 |
105 | RETURN
106 | articlesCount,
107 | a,
108 | [ (a)<-[:POSTED]-(u) | u ][0] AS author,
109 | [ (a)-[:HAS_TAG]->(t) | t ] AS tagList,
110 | CASE
111 | WHEN $userId IS NOT NULL
112 | THEN exists((a)<-[:FAVORITED]-({id: $userId}))
113 | ELSE false
114 | END AS favorited,
115 | size((a)<-[:FAVORITED]-()) AS favoritesCount
116 | `, params)
117 | .then(res => {
118 | const articlesCount = res.records.length ? res.records[0].get('articlesCount') : 0
119 | const articles = res.records.map(row => {
120 | return new Article(
121 | row.get('a'),
122 | new User(row.get('author')),
123 | row.get('tagList').map(tag => new Tag(tag)),
124 | row.get('favoritesCount'),
125 | row.get('favorited')
126 | )
127 | })
128 |
129 | return {
130 | articlesCount,
131 | articles: articles.map(a => a.toJson()),
132 | }
133 |
134 | })
135 | }
136 |
137 | getFeed() {
138 | const userId = ( this.request.user).getId()
139 |
140 | const skip = this.neo4jService.int( parseInt( this.request.query.offset) || 0)
141 | const limit = this.neo4jService.int( parseInt( this.request.query.limit) || 10)
142 |
143 | const params: Record = {
144 | userId: this.request.user ? ( this.request.user).getId() : null,
145 | skip, limit
146 | }
147 |
148 | const where = [];
149 |
150 | if ( this.request.query.author ) {
151 | where.push( `(a)<-[:POSTED]-({username: $author})` )
152 | params.author = this.request.query.author
153 | }
154 |
155 | if ( this.request.query.favorited ) {
156 | where.push( `(a)<-[:FAVORITED]-({username: $favorited})` )
157 | params.favorited = this.request.query.favorited
158 | }
159 |
160 | if ( this.request.query.tag ) {
161 | where.push( ` ALL (tag in $tags WHERE (a)-[:HAS_TAG]->({name: tag})) ` )
162 | params.tags = ( this.request.query.tag).split(',')
163 | }
164 |
165 | return this.neo4jService.read(`
166 | MATCH (current:User)-[:FOLLOWS]->(u)-[:POSTED]->(a)
167 | ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
168 |
169 | WITH count(a) AS articlesCount, collect(a) AS articles
170 |
171 | UNWIND articles AS a
172 |
173 | WITH articlesCount, a
174 | ORDER BY a.createdAt DESC
175 | SKIP $skip LIMIT $limit
176 |
177 | RETURN
178 | articlesCount,
179 | a,
180 | [ (a)<-[:POSTED]-(u) | u ][0] AS author,
181 | [ (a)-[:HAS_TAG]->(t) | t ] AS tagList,
182 | CASE
183 | WHEN $userId IS NOT NULL
184 | THEN exists((a)<-[:FAVORITED]-({id: $userId}))
185 | ELSE false
186 | END AS favorited,
187 | size((a)<-[:FAVORITED]-()) AS favoritesCount
188 | `, params)
189 | .then(res => {
190 | const articlesCount = res.records.length ? res.records[0].get('articlesCount') : 0
191 | const articles = res.records.map(row => {
192 | return new Article(
193 | row.get('a'),
194 | new User(row.get('author')),
195 | row.get('tagList').map(tag => new Tag(tag)),
196 | row.get('favoritesCount'),
197 | row.get('favorited')
198 | )
199 | })
200 |
201 | return {
202 | articlesCount,
203 | articles: articles.map(a => a.toJson()),
204 | }
205 | })
206 | }
207 |
208 | find(slug: string): Promise {
209 | return this.neo4jService.read(`
210 | MATCH (a:Article {slug: $slug})
211 | RETURN
212 | a,
213 | [ (a)<-[:POSTED]-(u) | u ][0] AS author,
214 | [ (a)-[:HAS_TAG]->(t) | t ] AS tagList,
215 | CASE
216 | WHEN $userId IS NOT NULL
217 | THEN exists((a)<-[:FAVORITED]-({id: $userId}))
218 | ELSE false
219 | END AS favorited,
220 | size((a)<-[:FAVORITED]-()) AS favoritesCount
221 | `, {
222 | slug,
223 | userId: this.request.user ? ( this.request.user).getId() : null,
224 | })
225 | .then(res => {
226 | if ( !res.records.length ) return undefined;
227 |
228 | const row = res.records[0]
229 |
230 | return new Article(
231 | row.get('a'),
232 | new User(row.get('author')),
233 | row.get('tagList').map(tag => new Tag(tag)),
234 | row.get('favoritesCount'),
235 | row.get('favorited')
236 | )
237 | })
238 | }
239 |
240 | update(slug: string, updates: Record): Promise {
241 | const tagList = updates.tagList || []
242 |
243 | return this.neo4jService.write(`
244 | MATCH (u:User {id: $userId})-[:POSTED]->(a:Article {slug: $slug})
245 |
246 | SET a += $updates
247 |
248 | FOREACH (r IN CASE WHEN size($tagList) > 0 THEN [ (a)-[r:HAS_TAG]->() | r] ELSE [] END |
249 | DELETE r
250 | )
251 |
252 | FOREACH ( name IN $tagList |
253 | MERGE (t:Tag {name: name}) ON CREATE SET t.id = randomUUID(), t.slug = apoc.text.slug(name)
254 | MERGE (a)-[:HAS_TAG]->(t)
255 | )
256 |
257 | RETURN
258 | a,
259 | [ (a)<-[:POSTED]-(ux) | ux ][0] AS author,
260 | [ (a)-[:HAS_TAG]->(t) | t ] AS tagList,
261 | CASE
262 | WHEN $userId IS NOT NULL
263 | THEN exists((a)<-[:FAVORITED]-({id: $userId}))
264 | ELSE false
265 | END AS favorited,
266 | size((a)<-[:FAVORITED]-()) AS favoritesCount
267 | `, {
268 | slug,
269 | userId: ( this.request.user).getId(),
270 | updates,
271 | tagList
272 | })
273 | .then(res => {
274 | if ( !res.records.length ) return undefined;
275 |
276 | const row = res.records[0]
277 |
278 | return new Article(
279 | row.get('a'),
280 | new User(row.get('author')),
281 | row.get('tagList').map(tag => new Tag(tag)),
282 | row.get('favoritesCount'),
283 | row.get('favorited')
284 | )
285 | })
286 |
287 | }
288 |
289 | delete(slug: string) {
290 | return this.neo4jService.write(`
291 | MATCH (u:User {id: $userId})-[:POSTED]->(a:Article {slug: $slug})
292 | FOREACH (c IN [ (a)<-[:ON]-(c:Comment) | c ] |
293 | DETACH DELETE c
294 | )
295 | DETACH DELETE a
296 | RETURN a
297 | `, { userId: ( this.request.user).getId(), slug })
298 | .then(res => res.records.length === 1)
299 | }
300 |
301 | favorite(slug: string): Promise {
302 | return this.neo4jService.write(`
303 | MATCH (a:Article {slug: $slug})
304 | MATCH (u:User {id: $userId})
305 |
306 | MERGE (u)-[r:FAVORITED]->(a)
307 | ON CREATE SET r.createdAt = datetime()
308 |
309 | RETURN a,
310 | [ (a)<-[:POSTED]-(ux) | ux ][0] AS author,
311 | [ (a)-[:HAS_TAG]->(t) | t ] AS tagList,
312 | CASE
313 | WHEN $userId IS NOT NULL
314 | THEN exists((a)<-[:FAVORITED]-({id: $userId}))
315 | ELSE false
316 | END AS favorited,
317 | size((a)<-[:FAVORITED]-()) AS favoritesCount
318 | `, {
319 | slug,
320 | userId: ( this.request.user).getId(),
321 | })
322 | .then(res => {
323 | if ( !res.records.length ) return undefined;
324 |
325 | const row = res.records[0]
326 |
327 | return new Article(
328 | row.get('a'),
329 | new User(row.get('author')),
330 | row.get('tagList').map(tag => new Tag(tag)),
331 | row.get('favoritesCount'),
332 | row.get('favorited')
333 | )
334 | })
335 | }
336 |
337 | unfavorite(slug: string): Promise {
338 | return this.neo4jService.write(`
339 | MATCH (a:Article {slug: $slug})
340 | MATCH (u:User {id: $userId})
341 |
342 | OPTIONAL MATCH (u)-[r:FAVORITED]->(a)
343 | DELETE r
344 |
345 | RETURN a,
346 | [ (a)<-[:POSTED]-(ux) | ux ][0] AS author,
347 | [ (a)-[:HAS_TAG]->(t) | t ] AS tagList,
348 | CASE
349 | WHEN $userId IS NOT NULL
350 | THEN exists((a)<-[:FAVORITED]-({id: $userId}))
351 | ELSE false
352 | END AS favorited,
353 | size((a)<-[:FAVORITED]-()) AS favoritesCount
354 | `, {
355 | slug,
356 | userId: ( this.request.user).getId(),
357 | })
358 | .then(res => {
359 | if ( !res.records.length ) return undefined;
360 |
361 | const row = res.records[0]
362 |
363 | return new Article(
364 | row.get('a'),
365 | new User(row.get('author')),
366 | row.get('tagList').map(tag => new Tag(tag)),
367 | row.get('favoritesCount'),
368 | row.get('favorited')
369 | )
370 | })
371 | }
372 |
373 | comment(slug: string, body: string): Promise {
374 | return this.neo4jService.write(`
375 | MATCH (a:Article {slug: $slug})
376 | MATCH (u:User {id: $userId})
377 |
378 | CREATE (u)-[:COMMENTED]->(c:Comment {
379 | id: randomUUID(),
380 | createdAt: datetime(),
381 | updatedAt: datetime(),
382 | body: $body
383 | })-[:FOR]->(a)
384 |
385 | RETURN c, u
386 | `, {
387 | slug,
388 | userId: ( this.request.user).getId(),
389 | body,
390 | })
391 | .then(res => {
392 | if ( !res.records.length ) return undefined;
393 |
394 | const row = res.records[0]
395 |
396 | return new Comment(row.get('c'), new User(row.get('u')))
397 | })
398 | }
399 |
400 | getComments(slug: string): Promise {
401 | return this.neo4jService.read(`
402 | MATCH (:Article {slug: $slug})<-[:FOR]-(c:Comment)<-[:COMMENTED]-(a)
403 | RETURN c, a
404 | ORDER BY c.createdAt DESC
405 | `, { slug })
406 | .then(res => {
407 | if ( !res.records.length ) return [];
408 |
409 | return res.records.map(row => new Comment(row.get('c'), new User(row.get('a'))))
410 | })
411 | }
412 |
413 | deleteComment(slug: string, commentId: string): Promise {
414 | return this.neo4jService.write(`
415 | MATCH (:Article {slug: $slug})<-[:FOR]-(c:Comment {id: $commentId})<-[:COMMENTED]-(a:User {id: $userId})
416 | DETACH DELETE c
417 |
418 | RETURN c, a
419 | `, {
420 | slug,
421 | userId: ( this.request.user).getId(),
422 | commentId,
423 | })
424 | .then(res => {
425 | return res.records.length === 1
426 | })
427 | }
428 |
429 |
430 |
431 | }
432 |
--------------------------------------------------------------------------------
/src/article/dto/create-article.dto.ts:
--------------------------------------------------------------------------------
1 | // {"article":{"title":"How to train your dragon", "description":"Ever wonder how?", "body":"Very carefully.", "tagList":["dragons","training"]}}
2 |
3 | import { IsNotEmpty, ValidateNested } from "class-validator";
4 | import { Type } from "class-transformer";
5 |
6 | class Article {
7 | @IsNotEmpty()
8 | title: string;
9 |
10 | @IsNotEmpty()
11 | description: string;
12 |
13 | @IsNotEmpty()
14 | body: string;
15 |
16 | tagList: string[];
17 | }
18 |
19 | export class CreateArticleDto {
20 |
21 | @ValidateNested()
22 | @Type(() => Article)
23 | article: Article;
24 |
25 | }
--------------------------------------------------------------------------------
/src/article/dto/create-comment.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, ValidateNested, IsObject } from "class-validator";
2 | import { Type } from "class-transformer";
3 |
4 | class Comment {
5 |
6 | @IsNotEmpty()
7 | body: string;
8 |
9 | }
10 |
11 | export class CreateCommentDto {
12 |
13 | @IsObject()
14 | @ValidateNested()
15 | @Type(() => Comment)
16 | comment: Comment;
17 |
18 | }
--------------------------------------------------------------------------------
/src/article/dto/update-article.dto.ts:
--------------------------------------------------------------------------------
1 | // {"article":{"title":"How to train your dragon", "description":"Ever wonder how?", "body":"Very carefully.", "tagList":["dragons","training"]}}
2 |
3 | import { IsNotEmpty, ValidateNested, IsOptional, IsObject } from "class-validator";
4 | import { Type } from "class-transformer";
5 |
6 | class Article {
7 | @IsOptional()
8 | title?: string;
9 |
10 | @IsOptional()
11 | description?: string;
12 |
13 | @IsOptional()
14 | body?: string;
15 |
16 | @IsOptional()
17 | tagList: string[];
18 | }
19 |
20 | export class UpdateArticleDto {
21 |
22 | @IsObject()
23 | @ValidateNested()
24 | @Type(() => Article)
25 | article: Article;
26 |
27 | }
--------------------------------------------------------------------------------
/src/article/entity/article.entity.ts:
--------------------------------------------------------------------------------
1 | import { Node } from 'neo4j-driver'
2 | import { User } from '../../user/entity/user.entity'
3 | import { Tag } from './tag.entity'
4 |
5 | export class Article {
6 | constructor(
7 | private readonly article: Node,
8 | private readonly author: User,
9 | private readonly tagList: Tag[],
10 |
11 | private readonly favoritesCount: number,
12 | private readonly favorited: boolean
13 | ) {}
14 |
15 | toJson(): Record {
16 | return {
17 | ...this.article.properties,
18 | favoritesCount: this.favoritesCount,
19 | favorited: this.favorited,
20 | author: this.author.toJson(),
21 | tagList: this.tagList.map(tag => tag.toJson()),
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/article/entity/comment.entity.ts:
--------------------------------------------------------------------------------
1 | import { Node } from 'neo4j-driver'
2 | import { User } from "../../user/entity/user.entity";
3 |
4 | export class Comment {
5 |
6 | constructor(private readonly node: Node, private readonly author: User) {}
7 |
8 | toJson() {
9 | return {
10 | ...this.node.properties,
11 | author: this.author.toJson(),
12 | }
13 | }
14 |
15 | }
--------------------------------------------------------------------------------
/src/article/entity/tag.entity.ts:
--------------------------------------------------------------------------------
1 | import { Node } from 'neo4j-driver'
2 |
3 | export class Tag {
4 | private readonly node: Node;
5 |
6 | constructor(node: Node) {
7 | this.node = node
8 | }
9 |
10 | toJson() {
11 | // @ts-ignore
12 | return this.node.properties.name
13 | }
14 | }
--------------------------------------------------------------------------------
/src/article/tag/tag.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { TagService } from './tag.service';
3 |
4 | describe('TagService', () => {
5 | let service: TagService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [TagService],
10 | }).compile();
11 |
12 | service = module.get(TagService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/article/tag/tag.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { Tag } from '../entity/tag.entity';
3 | import { Neo4jService } from 'nest-neo4j/dist';
4 |
5 | @Injectable()
6 | export class TagService {
7 |
8 | constructor(private readonly neo4jService: Neo4jService) {}
9 |
10 | list(): Promise {
11 | return this.neo4jService.read(`MATCH (t:Tag) RETURN t`)
12 | .then(res => res.records.map(row => new Tag(row.get('t'))))
13 |
14 |
15 | }
16 |
17 |
18 |
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/src/article/tags/tags.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { TagsController } from './tags.controller';
3 |
4 | describe('Tags Controller', () => {
5 | let controller: TagsController;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | controllers: [TagsController],
10 | }).compile();
11 |
12 | controller = module.get(TagsController);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(controller).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/article/tags/tags.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 | import { TagService } from '../tag/tag.service';
3 |
4 | @Controller('tags')
5 | export class TagsController {
6 |
7 | constructor(private readonly tagService: TagService) {}
8 |
9 | @Get()
10 | async getList() {
11 | const tags = await this.tagService.list();
12 |
13 | return {
14 | tags: tags.map(tag => tag.toJson()),
15 | }
16 | }
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { AppModule } from './app.module';
3 | import { ValidationPipe } from '@nestjs/common';
4 | import { UnprocessibleEntityValidationPipe } from './pipes/unprocessible-entity-validation.pipe';
5 |
6 | async function bootstrap() {
7 | const app = await NestFactory.create(AppModule);
8 | app.useGlobalPipes( new UnprocessibleEntityValidationPipe() );
9 | app.enableCors()
10 | await app.listen(3000);
11 | }
12 | bootstrap();
13 |
--------------------------------------------------------------------------------
/src/pipes/unprocessible-entity-validation.pipe.ts:
--------------------------------------------------------------------------------
1 | import { ValidationPipe, ValidationPipeOptions, UnprocessableEntityException } from "@nestjs/common";
2 | import { ValidationError } from "class-validator";
3 |
4 | export class UnprocessibleEntityValidationPipe extends ValidationPipe {
5 | constructor(options: ValidationPipeOptions = {}) {
6 | options.exceptionFactory = (originalErrors: ValidationError[]) => {
7 | const errors = originalErrors.map(
8 | (error: ValidationError) =>
9 | error.children.map(
10 | (child: ValidationError) => [ child.property, Object.values(child.constraints) ]
11 | )
12 | )
13 | .reduce((acc, errors) => acc.concat(errors), [])
14 |
15 | return new UnprocessableEntityException({
16 | errors: Object.fromEntries(errors)
17 | })
18 | }
19 |
20 | super(options)
21 | }
22 | }
--------------------------------------------------------------------------------
/src/user/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { User } from '../../user/entity/user.entity';
3 | import { JwtService } from '@nestjs/jwt';
4 | import { UserService } from '../user.service';
5 | import { EncryptionService } from '../encryption/encryption.service';
6 |
7 | @Injectable()
8 | export class AuthService {
9 |
10 | constructor(
11 | private readonly userService: UserService,
12 | private readonly encryptionService: EncryptionService,
13 | private readonly jwtService: JwtService
14 | ) {}
15 |
16 | createToken(user: User): string {
17 | const token = this.jwtService.sign(user.getClaims());
18 |
19 | return token
20 | }
21 |
22 | async validateUser(email: string, password: string): Promise {
23 | const user = await this.userService.findByEmail(email)
24 |
25 | if ( user && await this.encryptionService.compare(password, user.getPassword()) ) {
26 | return user
27 | }
28 |
29 | return undefined
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/src/user/auth/jwt.auth-guard.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, ExecutionContext, UnauthorizedException, CanActivate } from "@nestjs/common";
2 | import { AuthGuard } from "@nestjs/passport";
3 | import { Observable } from "rxjs";
4 |
5 | @Injectable()
6 | export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
7 |
8 | constructor(private readonly optional: boolean) {
9 | super()
10 | }
11 |
12 | static optional() {
13 | return new JwtAuthGuard(true)
14 | }
15 |
16 | canActivate(context: ExecutionContext): boolean | Promise | Observable {
17 | const superCanActivate = super.canActivate(context)
18 |
19 | if ( typeof superCanActivate === 'boolean' ) {
20 | return superCanActivate || this.optional
21 | }
22 | else if ( superCanActivate instanceof Promise ) {
23 | // @ts-ignore
24 | return (> superCanActivate).catch(e => {
25 | return this.optional
26 | })
27 | }
28 |
29 | return superCanActivate;
30 | }
31 |
32 | }
--------------------------------------------------------------------------------
/src/user/auth/jwt.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@nestjs/common";
2 | import { PassportStrategy } from "@nestjs/passport";
3 | import { ConfigService } from "@nestjs/config";
4 | import { ExtractJwt, Strategy } from "passport-jwt";
5 | import { UserService } from "../user.service";
6 | @Injectable()
7 | export class JwtStrategy extends PassportStrategy(Strategy) {
8 | constructor(
9 | private readonly configService: ConfigService,
10 | private readonly userService: UserService
11 |
12 | ) {
13 | super({
14 | jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('Token'),
15 | ignoreExpiration: false,
16 | secretOrKey: configService.get('JWT_SECRET'),
17 | })
18 | }
19 | async validate(payload: any) {
20 | return this.userService.findByEmail(payload.email)
21 | }
22 | }
--------------------------------------------------------------------------------
/src/user/auth/local-auth.guard.ts:
--------------------------------------------------------------------------------
1 | import { AuthGuard } from "@nestjs/passport";
2 | import { Injectable } from "@nestjs/common";
3 |
4 | @Injectable()
5 | export class LocalAuthGuard extends AuthGuard('local') {}
--------------------------------------------------------------------------------
/src/user/auth/local.strategy.ts:
--------------------------------------------------------------------------------
1 | // local.strategy.ts
2 | import { Strategy } from 'passport-local';
3 | import { PassportStrategy } from '@nestjs/passport';
4 | import { Injectable, UnauthorizedException } from '@nestjs/common';
5 | import { AuthService } from './auth.service';
6 | import { Request } from 'express';
7 | @Injectable()
8 | export class LocalStrategy extends PassportStrategy(Strategy) {
9 | constructor(private authService: AuthService) {
10 | super({
11 | usernameField: 'user[]email',
12 | passwordField: 'user[]password',
13 | });
14 | }
15 |
16 | async validate(username: string, password: string): Promise {
17 | const user = await this.authService.validateUser(username, password);
18 | if (!user) {
19 | throw new UnauthorizedException();
20 | }
21 | return user;
22 | }
23 | }
--------------------------------------------------------------------------------
/src/user/dto/create-user.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsEmail, ValidateNested, IsObject, Length } from 'class-validator'
2 | import { Type } from 'class-transformer'
3 |
4 | class UserDto {
5 |
6 | @IsNotEmpty()
7 | @IsEmail()
8 | email: string;
9 |
10 | @IsNotEmpty()
11 | @Length(1, 100)
12 | username: string;
13 |
14 | @IsNotEmpty()
15 | password: string;
16 |
17 | bio?: string = null;
18 | image?: string = null;
19 |
20 | }
21 |
22 | export class CreateUserDto {
23 |
24 | @IsObject()
25 | @ValidateNested()
26 | @Type(() => UserDto)
27 | user: UserDto;
28 |
29 | }
--------------------------------------------------------------------------------
/src/user/dto/login.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsEmail, ValidateNested, IsObject, Length } from 'class-validator'
2 | import { Type } from 'class-transformer'
3 |
4 | class UserDto {
5 |
6 | @IsNotEmpty()
7 | @IsEmail()
8 | email: string;
9 |
10 | @IsNotEmpty()
11 | password: string;
12 |
13 | }
14 |
15 | export class LoginDto {
16 |
17 | @IsObject()
18 | @ValidateNested()
19 | @Type(() => UserDto)
20 | user: UserDto;
21 |
22 | }
--------------------------------------------------------------------------------
/src/user/dto/update-user.dto.ts:
--------------------------------------------------------------------------------
1 | import { ValidateNested, IsNotEmpty, IsEmail } from "class-validator";
2 | import { Type } from "class-transformer";
3 |
4 | class UpdatedUser {
5 |
6 | @IsEmail()
7 | email?: string;
8 |
9 | password?: string;
10 |
11 | bio?: string = null;
12 | image?: string = null;
13 | }
14 |
15 | export class UpdateUserDto {
16 |
17 | @ValidateNested()
18 | @Type(() => UpdatedUser)
19 | user: UpdatedUser
20 | }
--------------------------------------------------------------------------------
/src/user/encryption/encryption.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { EncryptionService } from './encryption.service';
3 |
4 | describe('EncryptionService', () => {
5 | let service: EncryptionService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [EncryptionService],
10 | }).compile();
11 |
12 | service = module.get(EncryptionService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/user/encryption/encryption.service.ts:
--------------------------------------------------------------------------------
1 | import { hash, compare } from 'bcrypt'
2 | import { Injectable } from '@nestjs/common';
3 | import { ConfigService } from '@nestjs/config';
4 |
5 | @Injectable()
6 | export class EncryptionService {
7 |
8 | constructor(private readonly config: ConfigService) {}
9 |
10 | async hash(plain: string): Promise {
11 | return hash(plain, parseInt(this.config.get('HASH_ROUNDS', '10')))
12 | }
13 |
14 | async compare(plain: string, encrypted: string): Promise {
15 | return compare(plain, encrypted)
16 | }
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/src/user/entity/user.entity.ts:
--------------------------------------------------------------------------------
1 | import { Node } from 'neo4j-driver'
2 |
3 | export class User {
4 |
5 | constructor(private readonly node: Node) {}
6 |
7 | getId(): string {
8 | return (> this.node.properties).id
9 | }
10 |
11 | getPassword(): string {
12 | return (> this.node.properties).password
13 | }
14 |
15 | getClaims() {
16 | const { username, email, bio, image } = > this.node.properties
17 |
18 | return {
19 | sub: username,
20 | username,
21 | email,
22 | bio,
23 | image: image || 'https://picsum.photos/200',
24 | }
25 | }
26 |
27 | toJson(): Record {
28 | const { password, bio, image, ...properties } = > this.node.properties;
29 |
30 | return {
31 | image: image || 'https://picsum.photos/200',
32 | bio: bio || null,
33 | ...properties,
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/src/user/profile/profile.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { ProfileController } from './profile.controller';
3 |
4 | describe('Profile Controller', () => {
5 | let controller: ProfileController;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | controllers: [ProfileController],
10 | }).compile();
11 |
12 | controller = module.get(ProfileController);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(controller).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/user/profile/profile.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Param, NotFoundException, UseGuards, Request, Post, Delete } from '@nestjs/common';
2 | import { UserService } from '../user.service';
3 | import { use } from 'passport';
4 | import { JwtAuthGuard } from '../auth/jwt.auth-guard';
5 | import { User } from '../entity/user.entity';
6 |
7 | @Controller('profiles')
8 | export class ProfileController {
9 |
10 | constructor(private readonly userService: UserService) {}
11 |
12 | @UseGuards(JwtAuthGuard)
13 | @Get('/:username')
14 | async getIndex(@Request() request, @Param('username') username) {
15 | const user = await this.userService.findByUsername(username)
16 |
17 | if ( !user ) throw new NotFoundException(`User ${username} not found`)
18 |
19 | const following = await this.userService.isFollowing(user, request.user)
20 |
21 | return {
22 | profile: {
23 | ...user.toJson(),
24 | following,
25 | }
26 | }
27 | }
28 |
29 | @UseGuards(JwtAuthGuard)
30 | @Post('/:username/follow')
31 | async postFollow(@Request() request, @Param('username') username) {
32 | const user = await this.userService.follow(request.user, username)
33 |
34 | if ( !user ) throw new NotFoundException(`User ${username} not found`)
35 |
36 | return {
37 | profile: {
38 | ...user.toJson(),
39 | following: true,
40 | }
41 | }
42 | }
43 |
44 | @UseGuards(JwtAuthGuard)
45 | @Delete('/:username/follow')
46 | async deleteFollow(@Request() request, @Param('username') username) {
47 | const user = await this.userService.unfollow(request.user, username)
48 |
49 | if ( !user ) throw new NotFoundException(`User ${username} not found`)
50 |
51 | return {
52 | profile: {
53 | ...user.toJson(),
54 | following: false,
55 | }
56 | }
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/src/user/user.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Post, Body, Request, UseFilters, UseGuards, Get, Put, UseInterceptors } from '@nestjs/common';
2 | import { UserService } from './user.service';
3 | import { AuthService } from './auth/auth.service';
4 | import { User } from './entity/user.entity';
5 | import { Neo4jTypeInterceptor } from 'nest-neo4j';
6 | import { JwtAuthGuard } from './auth/jwt.auth-guard';
7 |
8 |
9 | @UseGuards(JwtAuthGuard)
10 | @UseInterceptors(Neo4jTypeInterceptor)
11 | @Controller('user')
12 | export class UserController {
13 |
14 | constructor(private readonly userService: UserService, private readonly authService: AuthService) {}
15 |
16 | @Get('/')
17 | async getIndex(@Request() request) {
18 | const token = this.authService.createToken(request.user)
19 |
20 | return {
21 | user: {
22 | ...request.user.toJson(),
23 | token,
24 | }
25 | }
26 | }
27 |
28 | @Put('/')
29 | async putIndex(@Request() request, @Body() body) {
30 | const user: User = request.user
31 | const updates = body.user
32 |
33 | const updatedUser = await this.userService.updateUser(user, updates)
34 |
35 | const token = this.authService.createToken(updatedUser)
36 |
37 | return {
38 | user: {
39 | ...updatedUser.toJson(),
40 | token,
41 | }
42 | }
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/src/user/user.module.ts:
--------------------------------------------------------------------------------
1 | import { Module, OnModuleInit } from '@nestjs/common';
2 | import { UserService } from './user.service';
3 | import { UsersController } from './users.controller';
4 | import { EncryptionService } from '../user/encryption/encryption.service';
5 | import { ConfigModule, ConfigService } from '@nestjs/config';
6 | import { JwtModule, JwtService } from '@nestjs/jwt';
7 | import { AuthService } from './auth/auth.service';
8 | import { LocalStrategy } from './auth/local.strategy';
9 | import { JwtStrategy } from './auth/jwt.strategy';
10 | import { PassportModule } from '@nestjs/passport';
11 | import { UserController } from './user.controller';
12 | import { ProfileController } from './profile/profile.controller';
13 | import { Neo4jService } from 'nest-neo4j/dist';
14 |
15 | @Module({
16 | imports: [
17 | PassportModule.register({ defaultStrategy: 'jwt' }),
18 | JwtModule.registerAsync({
19 | imports: [ ConfigModule ],
20 | inject: [ ConfigService, ],
21 | useFactory: (configService: ConfigService) => ({
22 | secret: configService.get('JWT_SECRET'),
23 | signOptions: {
24 | expiresIn: configService.get('JWT_EXPIRES_IN'),
25 | },
26 | })
27 | }),
28 | ],
29 | providers: [UserService, LocalStrategy, JwtStrategy, AuthService, EncryptionService],
30 | controllers: [UserController, UsersController, ProfileController],
31 | exports: [],
32 | })
33 | export class UserModule implements OnModuleInit {
34 |
35 | constructor(private readonly neo4jService: Neo4jService) {}
36 |
37 | async onModuleInit() {
38 | await this.neo4jService.write(`CREATE CONSTRAINT ON (u:User) ASSERT u.id IS UNIQUE`).catch(() => {})
39 | await this.neo4jService.write(`CREATE CONSTRAINT ON (u:User) ASSERT u.username IS UNIQUE`).catch(() => {})
40 | await this.neo4jService.write(`CREATE CONSTRAINT ON (u:User) ASSERT u.email IS UNIQUE`).catch(() => {})
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/src/user/user.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { UserService } from './user.service';
3 |
4 | describe('UserService', () => {
5 | let service: UserService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [UserService],
10 | }).compile();
11 |
12 | service = module.get(UserService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/user/user.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { Neo4jService } from 'nest-neo4j/dist';
3 | import { EncryptionService } from './encryption/encryption.service';
4 | import { User } from './entity/user.entity'
5 |
6 | @Injectable()
7 | export class UserService {
8 |
9 | constructor(private readonly neo4jService: Neo4jService, private readonly encryptionService: EncryptionService) {}
10 |
11 | async create(username: string, password: string, email: string, bio?: string, image?: string): Promise {
12 | return this.neo4jService.write(`
13 | CREATE (u:User {
14 | id: randomUUID(),
15 | username: $username,
16 | password: $password,
17 | email: $email,
18 | bio: $bio,
19 | image: $image
20 | })
21 | RETURN u
22 | `, {
23 | username,
24 | password: await this.encryptionService.hash(password),
25 | email,
26 | bio: bio || null,
27 | image: image || null,
28 | })
29 | .then(({ records }) => new User(records[0].get('u')) )
30 | }
31 |
32 | async findByEmail(email: string): Promise {
33 | const res = await this.neo4jService.read('MATCH (u:User {email: $email}) RETURN u', { email })
34 |
35 | return res.records.length ? new User(res.records[0].get('u')) : undefined
36 | }
37 |
38 | async findByUsername(username: string): Promise {
39 | const res = await this.neo4jService.read(`
40 | MATCH (u:User {username: $username})
41 | RETURN u
42 | `, {
43 | username
44 | })
45 |
46 | return res.records.length ? new User(res.records[0].get('u')) : undefined
47 | }
48 |
49 | async updateUser(user: User, updates: Record): Promise {
50 | if ( updates.password ) updates.password = await this.encryptionService.hash(updates.password)
51 |
52 | return this.neo4jService.write(`
53 | MATCH (u:User {id: $id})
54 | SET u.updatedAt = localdatetime(), u += $updates
55 | RETURN u
56 | `, { id: user.getId(), updates })
57 | .then(({ records }) => new User(records[0].get('u')) )
58 | }
59 |
60 | async isFollowing(target: User, current: User): Promise {
61 | return this.neo4jService.read(`
62 | MATCH (target:User {id: $targetId})<-[:FOLLOWS]-(current:User {id: $currentId})
63 | RETURN count(*) AS count
64 | `, {
65 | targetId: target.getId(),
66 | currentId: current.getId(),
67 | })
68 | .then(res => {
69 | return res.records[0].get('count') > 0
70 | })
71 | }
72 |
73 | follow(user: User, username: string): Promise {
74 | return this.neo4jService.write(`
75 | MATCH (target:User {username: $username})
76 | MATCH (current:User {id: $userId})
77 |
78 | MERGE (current)-[r:FOLLOWS]->(target)
79 | ON CREATE SET r.createdAt = datetime()
80 |
81 | RETURN target
82 | `, { username, userId: user.getId() })
83 | .then(res => {
84 | if ( res.records.length == 0 ) return undefined
85 |
86 | return new User(res.records[0].get('target'))
87 | })
88 | }
89 |
90 | unfollow(user: User, username: string): Promise {
91 | return this.neo4jService.write(`
92 | MATCH (target:User {username: $username})
93 |
94 | FOREACH (rel IN [ (target)<-[r:FOLLOWS]-(:User {id: $userId}) | r ] |
95 | DELETE rel
96 | )
97 |
98 | RETURN target
99 | `, { username, userId: user.getId() })
100 | .then(res => {
101 | if ( res.records.length == 0 ) return undefined
102 |
103 | return new User(res.records[0].get('target'))
104 | })
105 | }
106 |
107 |
108 |
109 | }
110 |
--------------------------------------------------------------------------------
/src/user/users.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Post, Body, Request, UseFilters, UseGuards, Get } from '@nestjs/common';
2 | import { CreateUserDto } from './dto/create-user.dto';
3 | import { LoginDto } from './dto/login.dto';
4 | import { UserService } from './user.service';
5 | import { AuthService } from './auth/auth.service';
6 | import { User } from './entity/user.entity';
7 | import { Neo4jErrorFilter } from 'nest-neo4j';
8 | import { LocalAuthGuard } from './auth/local-auth.guard';
9 | import { JwtAuthGuard } from './auth/jwt.auth-guard';
10 |
11 |
12 | @Controller('users')
13 | export class UsersController {
14 |
15 | constructor(private readonly userService: UserService, private readonly authService: AuthService) {}
16 |
17 | @UseFilters(Neo4jErrorFilter)
18 | @Post('/')
19 | async postIndex(@Body() createUserDto: CreateUserDto): Promise {
20 | const user: User = await this.userService.create(
21 | createUserDto.user.username,
22 | createUserDto.user.password,
23 | createUserDto.user.email,
24 | createUserDto.user.bio,
25 | createUserDto.user.image
26 | )
27 |
28 | const token = this.authService.createToken(user)
29 |
30 | return {
31 | user: {
32 | ...user.toJson(),
33 | token,
34 | }
35 | }
36 | }
37 |
38 | @UseGuards(LocalAuthGuard)
39 | @Post('/login')
40 | async postLogin(@Request() request, loginDto: LoginDto) {
41 | const token = this.authService.createToken(request.user)
42 |
43 | return {
44 | user: {
45 | ...request.user.toJson(),
46 | token,
47 | }
48 | }
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/test/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { INestApplication, ValidationPipe, UnprocessableEntityException } from '@nestjs/common';
3 | import * as request from 'supertest';
4 | import { AppModule } from './../src/app.module';
5 | import { ValidationError } from 'class-validator';
6 | import { UnprocessibleEntityValidationPipe } from '../src/pipes/unprocessible-entity-validation.pipe';
7 | import { Neo4jService } from 'nest-neo4j/dist';
8 | import { O_TRUNC } from 'constants';
9 |
10 | describe('AppController (e2e)', () => {
11 | let app: INestApplication;
12 | let neo4j: Neo4jService
13 | let api
14 |
15 | // Test Credentials
16 | const username = Math.random().toString()
17 | const email = `${username}@neo4j.com`
18 | const password = Math.random().toString()
19 | let token
20 |
21 | beforeEach(async () => {
22 | const moduleFixture: TestingModule = await Test.createTestingModule({
23 | imports: [AppModule],
24 | }).compile();
25 |
26 | app = moduleFixture.createNestApplication();
27 | app.useGlobalPipes(new UnprocessibleEntityValidationPipe());
28 | await app.init();
29 |
30 | api = app.getHttpServer()
31 | neo4j = app.get(Neo4jService)
32 | });
33 |
34 | describe('/users', () => {
35 | describe('POST / → sign up', () => {
36 | it('should return 422 on missing info', () => {
37 | request(api)
38 | .post('/users')
39 | .send({ user: {} })
40 | .expect(422)
41 | .expect(res => {
42 | expect(res.body.errors).toBeInstanceOf(Object)
43 | expect(res.body.errors.email).toBeInstanceOf(Array)
44 | expect(res.body.errors.password).toBeInstanceOf(Array)
45 | expect(res.body.errors.username).toBeInstanceOf(Array)
46 | })
47 | })
48 |
49 | it('should create user', () => {
50 | request(api)
51 | .post('/users')
52 | .send({ user: { username, email, password } })
53 | .expect(201)
54 | .expect(res => {
55 | expect(res.body.user).toBeInstanceOf(Object)
56 | expect(res.body.user.email).toEqual(email)
57 | expect(res.body.user.username).toEqual(username)
58 | expect(res.body.user.password).toBeUndefined()
59 | expect(res.body.user.bio).toBeDefined()
60 | expect(res.body.user.image).toBeDefined()
61 | })
62 | })
63 | })
64 |
65 | describe('POST /login → login', () => {
66 | // Guards run before pipes so this won't return a 422: https://github.com/nestjs/passport/issues/129
67 | // it('should return 422 on missing info', () => {
68 | // request(api)
69 | // .post('/users/login')
70 | // .send({ user: {} })
71 | // .expect(422)
72 | // .expect(res => {
73 | // expect(res.body.errors).toBeInstanceOf(Object)
74 | // expect(res.body.errors.password).toBeInstanceOf(Array)
75 | // expect(res.body.errors.username).toBeInstanceOf(Array)
76 | // })
77 | // })
78 |
79 | it('should return 401 on bad username', () => {
80 | request(api)
81 | .post('/users/login')
82 | .send({ user: { email: 'unknown@neo4j.com' } })
83 | .expect(401)
84 | })
85 |
86 | it('should return 401 on bad password', () => {
87 | request(api)
88 | .post('/users/login')
89 | .send({ user: { email, password: 'badpassword' } })
90 | .expect(401)
91 | })
92 |
93 | it('should return 201 with user profile and token on success', () => {
94 | request(api)
95 | .post('/users/login')
96 | .send({ user: { email, password } })
97 | .expect(201)
98 | .expect(res => {
99 | expect(res.body.user).toBeInstanceOf(Object)
100 | expect(res.body.user.email).toEqual(email)
101 | expect(res.body.user.username).toEqual(username)
102 | expect(res.body.user.password).toBeUndefined()
103 | expect(res.body.user.bio).toBeDefined()
104 | expect(res.body.user.image).toBeDefined()
105 | expect(res.body.user.token).toBeDefined()
106 |
107 | token = res.body.user.token
108 | })
109 | })
110 | })
111 | })
112 |
113 | describe('/user', () => {
114 | describe('GET / → User info', () => {
115 | it('should require a valid token', () => {
116 | request(api)
117 | .get('/user')
118 | .expect(403)
119 | })
120 |
121 | it('should return user info and generate a new token', () => {
122 | request(api)
123 | .get('/user')
124 | .set({ Authorization: `Token ${token}` })
125 | .expect(200)
126 | .expect(res => {
127 | expect(res.body.user).toBeInstanceOf(Object)
128 | expect(res.body.user.email).toEqual(email)
129 | expect(res.body.user.username).toEqual(username)
130 | expect(res.body.user.password).toBeUndefined()
131 | // TODO: Re-enable after publishing nest-neo4j
132 | // expect(res.body.user.bio).toBeDefined()
133 | expect(res.body.user.image).toBeDefined()
134 | expect(res.body.user.token).toBeDefined()
135 |
136 | token = res.body.user.token
137 | })
138 | })
139 | })
140 |
141 | describe('PUT / → Update user', () => {
142 | it('should require a valid token', () => {
143 | request(api)
144 | .put('/user')
145 | .expect(403)
146 | })
147 |
148 | it('should update user info', () => {
149 | let bio = 'Interesting'
150 | request(api)
151 | .put('/user')
152 | .set({ Authorization: `Token ${token}` })
153 | .send({ user: { bio } })
154 | .expect(200)
155 | .expect(res => {
156 | expect(res.body.user).toBeInstanceOf(Object)
157 | expect(res.body.user.email).toEqual(email)
158 | expect(res.body.user.username).toEqual(username)
159 | expect(res.body.user.password).toBeUndefined()
160 | expect(res.body.user.image).toBeDefined()
161 | expect(res.body.user.token).toBeDefined()
162 | expect(res.body.user.bio).toEqual(bio)
163 |
164 | token = res.body.user.token
165 | })
166 | })
167 | })
168 |
169 | })
170 |
171 | describe('/articles', () => {
172 | const jane = 'jane'
173 | const johnjacob = 'johnjacob'
174 | const tag = Math.random().toString() // 'unique'
175 | const slug = 'test-1'
176 | const title = 'Building Applications with Neo4j and Typescript'
177 | const otherCommentId = '1234'
178 | let articleCount
179 |
180 | const article = {
181 | title: "How to train your dragon",
182 | description: "Ever wonder how?",
183 | body: "Very carefully.",
184 | tagList: ["dragons", "training"],
185 | }
186 | let newSlug
187 | let commentId
188 |
189 | beforeAll(() => neo4j.write(`
190 | MERGE (johnjacob:User {username: $johnjacob})
191 | SET johnjacob:Test,
192 | johnjacob += { id: randomUUID(), email: $johnjacob +'@neo4j.com', bio: $johnjacob }
193 |
194 | MERGE (jane:User {username: $jane}) SET jane:Test
195 |
196 | MERGE (neo4j:Tag {name: 'neo4j'})
197 | MERGE (typescript:Tag {name: 'typescript'})
198 | MERGE (nestjs:Tag {name: 'nestjs'})
199 | MERGE (tag:Tag:Test {name: $tag})
200 |
201 | MERGE (a1:Article:Test {
202 | slug: $slug,
203 | title: $title,
204 | description: 'Write some code'
205 |
206 | })
207 | SET a1 += { id: randomUUID(), createdAt: datetime(), updatedAt: datetime() }
208 | MERGE (johnjacob)-[:POSTED]->(a1)
209 | MERGE (a1)-[:HAS_TAG]->(neo4j)
210 | MERGE (a1)-[:HAS_TAG]->(typescript)
211 | MERGE (a1)-[:HAS_TAG]->(nestjs)
212 |
213 | MERGE (a2:Article:Test {
214 | slug: 'test-2',
215 | title: 'testing Applications with Neo4j and Typescript',
216 | description: 'Test the code'
217 | })
218 | SET a2 += { id: randomUUID(), createdAt: datetime(), updatedAt: datetime() }
219 | MERGE (johnjacob)-[:POSTED]->(a2)
220 | MERGE (a2)-[:HAS_TAG]->(neo4j)
221 | MERGE (a2)-[:HAS_TAG]->(typescript)
222 | MERGE (a2)-[:HAS_TAG]->(tag)
223 |
224 | MERGE (jane)-[:FAVORITED]->(a1)
225 |
226 | MERGE (jane)-[:COMMENTED]->(:Comment:Test {
227 | id: $otherCommentId,
228 | body: 'Great!',
229 | createdAt: datetime(),
230 | updatedAt: datetime()
231 | })-[:FOR]->(a1)
232 |
233 | WITH distinct 0 as n
234 |
235 | MATCH (a:Article) WITH count(a) AS articleCount
236 | RETURN articleCount
237 | `, { jane, johnjacob, tag, slug, title, otherCommentId, }).then(res => articleCount = res.records[0].get('articleCount').toNumber()))
238 |
239 | afterAll(() => neo4j.write('MATCH (a:Test) DETACH DELETE a'))
240 |
241 | describe('GET / → List articles', () => {
242 | it('should return a list of articles without token', () => {
243 | request(api)
244 | .get('/articles')
245 | .expect(200)
246 | .expect(res => {
247 | expect(res.body.articles).toBeInstanceOf(Array)
248 | expect(res.body.articles.length).toEqual(articleCount)
249 |
250 | res.body.articles.map(article => {
251 | expect(article.id).toBeDefined()
252 | expect(article.title).toBeDefined()
253 | expect(article.slug).toBeDefined()
254 | expect(article.createdAt).toBeDefined()
255 | expect(/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/.test(article.createdAt)).toBeTruthy()
256 | expect(article.updatedAt).toBeDefined()
257 | expect(/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/.test(article.updatedAt)).toBeTruthy()
258 | expect(article.description).toBeDefined()
259 | expect(article.tagList).toBeInstanceOf(Array)
260 | expect(article.tagList.length).toBeGreaterThan(0)
261 | expect(article.favorited).toBeFalsy()
262 | expect(article.favoritesCount).toBeDefined()
263 | expect(Number.isInteger(article.favoritesCount)).toBeTruthy()
264 | expect(article.author).toBeDefined()
265 | })
266 | })
267 | })
268 |
269 | it('should return a list of articles with token', () => {
270 | request(api)
271 | .get('/articles')
272 | .send({ Authorization: `Token ${token}` })
273 | .expect(200)
274 | })
275 |
276 | it('should apply pagination', () => {
277 | request(api)
278 | .get('/articles?limit=1')
279 | .expect(200)
280 | .expect(res => {
281 | expect(res.body.articles).toBeInstanceOf(Array)
282 | expect(res.body.articles.length).toEqual(1)
283 | })
284 | })
285 |
286 | it('should apply pagination', () => {
287 | request(api)
288 | .get('/articles?limit=1')
289 | .expect(200)
290 | .expect(res => {
291 | expect(res.body.articles).toBeInstanceOf(Array)
292 | expect(res.body.articles.length).toEqual(1)
293 | })
294 | })
295 |
296 | it('should filter by author', () => {
297 | request(api)
298 | .get(`/articles?author=${johnjacob}`)
299 | .expect(200)
300 | .expect(res => {
301 | expect(res.body.articles).toBeInstanceOf(Array)
302 | expect(res.body.articles.length).toEqual(2)
303 | expect(res.body.articles.filter(article => article.author.username !== johnjacob)).toEqual([])
304 | })
305 | })
306 |
307 | it('should filter by favorited', () => {
308 | request(api)
309 | .get(`/articles?favorited=${jane}`)
310 | .expect(200)
311 | .expect(res => {
312 | expect(res.body.articles).toBeInstanceOf(Array)
313 | expect(res.body.articles.length).toEqual(1)
314 | })
315 | })
316 |
317 | it('should filter by tag', () => {
318 | request(api)
319 | .get(`/articles?tag=${tag}`)
320 | .expect(200)
321 | .expect(res => {
322 | expect(res.body.articles).toBeInstanceOf(Array)
323 | expect(res.body.articles.length).toEqual(1)
324 |
325 | expect(res.body.articles.filter(a => a.tagList.includes(tag)).length).toEqual(res.body.articles.length)
326 | })
327 | })
328 | })
329 |
330 | describe('GET /:slug → Article by slug', () => {
331 | it('should return 404 when article not found', () => {
332 | request(api)
333 | .get('/articles/unknown-slug')
334 | .expect(404)
335 | })
336 |
337 | it('should return article by slug', () => {
338 | request(api)
339 | .get(`/articles/${slug}`)
340 | .expect(200)
341 | .expect(res => {
342 | expect(res.body.article).toBeDefined()
343 |
344 | expect(res.body.article.id).toBeDefined()
345 | expect(res.body.article.title).toBeDefined()
346 | expect(res.body.article.slug).toBeDefined()
347 | expect(res.body.article.createdAt).toBeDefined()
348 | expect(/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/.test(res.body.article.createdAt)).toBeTruthy()
349 | expect(res.body.article.updatedAt).toBeDefined()
350 | expect(/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/.test(res.body.article.updatedAt)).toBeTruthy()
351 | expect(res.body.article.description).toBeDefined()
352 | expect(res.body.article.tagList).toBeInstanceOf(Array)
353 | expect(res.body.article.tagList.length).toBeGreaterThan(0)
354 | expect(res.body.article.favorited).toBeFalsy()
355 | expect(res.body.article.favoritesCount).toBeDefined()
356 | expect(Number.isInteger(res.body.article.favoritesCount)).toBeTruthy()
357 | expect(res.body.article.author).toBeDefined()
358 | expect(res.body.article.author.username).toEqual(johnjacob)
359 | })
360 | })
361 |
362 | it('should return article by slug with token', () => {
363 | request(api)
364 | .get(`/articles/${slug}`)
365 | .send({ Authorization: `Token ${token} ` })
366 | .expect(200)
367 | .expect(res => {
368 | expect(res.body.article).toBeDefined()
369 |
370 | expect(res.body.article.id).toBeDefined()
371 | expect(res.body.article.title).toBeDefined()
372 | expect(res.body.article.slug).toBeDefined()
373 | expect(res.body.article.createdAt).toBeDefined()
374 | expect(/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/.test(res.body.article.createdAt)).toBeTruthy()
375 | expect(res.body.article.updatedAt).toBeDefined()
376 | expect(/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/.test(res.body.article.updatedAt)).toBeTruthy()
377 | expect(res.body.article.description).toBeDefined()
378 | expect(res.body.article.tagList).toBeInstanceOf(Array)
379 | expect(res.body.article.tagList.length).toBeGreaterThan(0)
380 | expect(res.body.article.favorited).toBeFalsy()
381 | expect(res.body.article.favoritesCount).toBeDefined()
382 | expect(Number.isInteger(res.body.article.favoritesCount)).toBeTruthy()
383 | expect(res.body.article.author).toBeDefined()
384 | expect(res.body.article.author.username).toEqual(johnjacob)
385 | })
386 | })
387 | })
388 |
389 | describe('POST / → Create article', () => {
390 | it('should require a valid token', () => {
391 | request(api)
392 | .post('/articles')
393 | .expect(403)
394 | })
395 |
396 | it('should return 422 on missing info', () => {
397 | request(api)
398 | .post('/articles')
399 | .set({ Authorization: `Token ${token}` })
400 | .send({ article: {} })
401 | .expect(422)
402 | .expect(res => {
403 | expect(res.body.errors).toBeInstanceOf(Object)
404 | expect(res.body.errors.title).toBeInstanceOf(Array)
405 | expect(res.body.errors.description).toBeInstanceOf(Array)
406 | expect(res.body.errors.body).toBeInstanceOf(Array)
407 | })
408 | })
409 |
410 | it('should create a new article', () => {
411 | request(api)
412 | .post('/articles')
413 | .set({ Authorization: `Token ${token}` })
414 | .send({ article })
415 | .expect(201)
416 | .expect(res => {
417 | expect(res.body.article).toBeInstanceOf(Object)
418 | expect(res.body.article.title).toEqual(article.title)
419 | expect(res.body.article.description).toEqual(article.description)
420 | expect(res.body.article.body).toEqual(article.body)
421 | expect(res.body.article.tagList.sort()).toEqual(article.tagList)
422 | expect(res.body.article.slug).toBeDefined()
423 |
424 | expect(res.body.article.author).toBeDefined()
425 | expect(res.body.article.author.username).toEqual(username)
426 |
427 | newSlug = res.body.article.slug
428 | })
429 | })
430 |
431 | it('should return newly created article', () => {
432 | request(api)
433 | .get(`/articles/${newSlug}`)
434 | .expect(200)
435 | .expect(res => {
436 | expect(res.body.article).toBeInstanceOf(Object)
437 | expect(res.body.article.title).toEqual(article.title)
438 | expect(res.body.article.description).toEqual(article.description)
439 | expect(res.body.article.body).toEqual(article.body)
440 | expect(res.body.article.tagList.sort()).toEqual(article.tagList)
441 | expect(res.body.article.slug).toBeDefined()
442 |
443 | expect(res.body.article.author).toBeDefined()
444 | expect(res.body.article.author.username).toEqual(username)
445 |
446 | newSlug = res.body.article.slug
447 | })
448 | })
449 | })
450 |
451 | describe('PUT / → Update article', () => {
452 | it('should require a valid token', () => {
453 | request(api)
454 | .put(`/articles/${newSlug}`)
455 | .send({ article: {} })
456 | .expect(403)
457 | })
458 |
459 | it('should return 422 on missing info', () => {
460 | request(api)
461 | .put(`/articles/${slug}`)
462 | .set({ Authorization: `Token ${token}` })
463 | .expect(422)
464 | })
465 |
466 | it('should not let you edit article from another author', () => {
467 | request(api)
468 | .put(`/articles/${slug}`)
469 | .set({ Authorization: `Token ${token}` })
470 | .send({ article: { body: 'newbody' } })
471 | // TODO: 403
472 | // .expect(403)
473 | .expect(404)
474 | })
475 |
476 | it('should update and return updated record', () => {
477 | let body = 'Updated body'
478 |
479 | request(api)
480 | .put(`/articles/${newSlug}`)
481 | .set({ Authorization: `Token ${token}` })
482 | .send({ article: { body } })
483 | .expect(200)
484 | .expect(res => {
485 | expect(res.body.article.title).toEqual(article.title)
486 | expect(res.body.article.description).toEqual(article.description)
487 | expect(res.body.article.tagList.sort()).toEqual(article.tagList)
488 | expect(res.body.article.slug).toBeDefined()
489 | expect(res.body.article.author).toBeDefined()
490 | expect(res.body.article.author.username).toEqual(username)
491 |
492 | expect(res.body.article.body).toEqual(body)
493 | })
494 | })
495 | })
496 |
497 | describe('POST /:slug/favorite → Favorite an article', () => {
498 | it('should require a valid token', () => {
499 | request(api)
500 | .post(`/articles/${slug}/favorite`)
501 | .expect(403)
502 | })
503 |
504 | it('should create favorited relationship and return updated record', () => {
505 | request(api)
506 | .post(`/articles/${newSlug}/favorite`)
507 | .set({ Authorization: `Token ${token}` })
508 | .expect(201)
509 | .expect(res => {
510 | expect(res.body.article.slug).toEqual(newSlug)
511 | expect(res.body.article.favorited).toEqual(true)
512 | expect(res.body.article.favoritesCount).toBeGreaterThan(0)
513 | })
514 | })
515 | })
516 |
517 | describe('DELETE /:slug/favorite → Remove a favorite', () => {
518 | it('should require a valid token', () => {
519 | request(api)
520 | .delete(`/articles/${slug}/favorite`)
521 | .expect(403)
522 | })
523 |
524 | it('should create favorited relationship and return updated record', () => {
525 | request(api)
526 | .delete(`/articles/${newSlug}/favorite`)
527 | .set({ Authorization: `Token ${token}` })
528 | .expect(200)
529 | .expect(res => {
530 | expect(res.body.article.slug).toEqual(newSlug)
531 | expect(res.body.article.favorited).toEqual(false)
532 | })
533 | })
534 | })
535 |
536 | describe('GET /:slug/comments → List comments for an article', () => {
537 | it('should return a list of comments', () => {
538 | request(api)
539 | .get(`/articles/${slug}/comments`)
540 | .expect(200)
541 | .expect(res => {
542 | expect(res.body.comments).toBeInstanceOf(Array)
543 | expect(res.body.comments.length).toEqual(1)
544 | expect(res.body.comments[0].body).toEqual('Great!')
545 | expect(res.body.comments[0].createdAt).toBeDefined()
546 | expect(res.body.comments[0].updatedAt).toBeDefined()
547 | expect(res.body.comments[0].author).toBeInstanceOf(Object)
548 | expect(res.body.comments[0].author.username).toEqual(jane)
549 | })
550 | })
551 | it('should return a list of comments with token', () => {
552 | request(api)
553 | .get(`/articles/${slug}/comments`)
554 | .set({ Authorization: `Token ${token}` })
555 | .expect(200)
556 | .expect(res => {
557 | expect(res.body.comments).toBeInstanceOf(Array)
558 | expect(res.body.comments.length).toEqual(1)
559 | expect(res.body.comments[0].body).toEqual('Great!')
560 | expect(res.body.comments[0].createdAt).toBeDefined()
561 | expect(res.body.comments[0].updatedAt).toBeDefined()
562 | expect(res.body.comments[0].author).toBeInstanceOf(Object)
563 | expect(res.body.comments[0].author.username).toEqual(jane)
564 | })
565 | })
566 | })
567 |
568 | describe('POST /:slug/comments/:commentId → Post a comment', () => {
569 | let body = 'Hello!'
570 |
571 |
572 | it('should require a valid token', () => {
573 | request(api)
574 | .post(`/articles/${slug}/comments`)
575 | .expect(403)
576 | })
577 |
578 | it('should return 422 on missing info', () => {
579 | request(api)
580 | .post(`/articles/${slug}/comments`)
581 | .set({ Authorization: `Token ${token}` })
582 | .send({ comment: { } })
583 | .expect(422)
584 | })
585 |
586 | it('should return 404 if article not found', () => {
587 | request(api)
588 | .post('/articles/not-found/comments')
589 | .set({ Authorization: `Token ${token}` })
590 | .send({ comment: { body } })
591 | .expect(404)
592 | })
593 |
594 | it('should create a new comment', () => {
595 | request(api)
596 | .post(`/articles/${slug}/comments`)
597 | .set({ Authorization: `Token ${token}` })
598 | .send({ comment: { body: 'Hello!' } })
599 | .expect(res => {
600 | expect(res.body.comment).toBeInstanceOf(Object)
601 | expect(res.body.comment.id).toBeDefined()
602 | expect(res.body.comment.createdAt).toBeDefined()
603 | expect(res.body.comment.updatedAt).toBeDefined()
604 | expect(res.body.comment.body).toEqual(body)
605 | expect(res.body.comment.author).toBeInstanceOf(Object)
606 | expect(res.body.comment.author.username).toEqual(username)
607 |
608 | commentId = res.body.comment.id
609 | })
610 | })
611 |
612 | it('should return comment at top of GET request', () => {
613 | request(api)
614 | .get(`/articles/${slug}/comments`)
615 | .expect(res => {
616 | expect(res.body.comments).toBeInstanceOf(Object)
617 | expect(res.body.comments.length).toEqual(2)
618 | expect(res.body.comments[0].id).toEqual(commentId)
619 | expect(res.body.comments[0].createdAt).toBeDefined()
620 | expect(res.body.comments[0].updatedAt).toBeDefined()
621 | expect(res.body.comments[0].body).toEqual(body)
622 | expect(res.body.comments[0].author).toBeInstanceOf(Object)
623 | expect(res.body.comments[0].author.username).toEqual(username)
624 | })
625 | })
626 | })
627 |
628 | describe('DELETE /:slug/favorite/:commentId → Delete a comment', () => {
629 | it('should require a valid token', () => {
630 | request(api)
631 | .delete(`/articles/${slug}/comments/${commentId}`)
632 | .expect(403)
633 | })
634 |
635 | it('shouldnt let the user delete someone elses comment', () => {
636 | request(api)
637 | .delete(`/articles/${slug}/comments/${otherCommentId}`)
638 | .set({ Authorization: `Token ${token}` })
639 | // TODO: .expect(403)
640 | .expect(404)
641 | })
642 |
643 | it('should delete comment', () => {
644 | request(api)
645 | .delete(`/articles/${slug}/comments/${commentId}`)
646 | .set({ Authorization: `Token ${token}` })
647 | .expect(200)
648 | })
649 | })
650 |
651 | describe('DELETE /:slug → Create article', () => {
652 | it('should require a valid token', () => {
653 | request(api)
654 | .delete(`/articles/${newSlug}`)
655 | .send({ article: {} })
656 | .expect(403)
657 | })
658 |
659 | it('should not let you delete article from another author', () => {
660 | request(api)
661 | .delete(`/articles/${slug}`)
662 | .set({ Authorization: `Token ${token}` })
663 | .send({ article: { body: 'newbody' } })
664 | // TODO: 403
665 | // .expect(403)
666 | .expect(404)
667 | })
668 |
669 | it('should delete the users article', () => {
670 | request(api)
671 | .delete(`/articles/${newSlug}`)
672 | .set({ Authorization: `Token ${token}` })
673 | .expect(200)
674 | })
675 | })
676 | })
677 |
678 |
679 | // afterAll(() => neo4j.write(`MATCH (u:User {username: $username}) DETACH DELETE u`, { username }))
680 | });
681 |
--------------------------------------------------------------------------------
/test/jest-e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts"],
3 | "rootDir": ".",
4 | "testEnvironment": "node",
5 | "testRegex": ".e2e-spec.ts$",
6 | "transform": {
7 | "^.+\\.(t|j)s$": "ts-jest"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "lib": ["es2019"],
10 | "target": "es2017",
11 | "sourceMap": true,
12 | "outDir": "./dist",
13 | "baseUrl": "./",
14 | "incremental": true,
15 | "allowJs": true
16 | }
17 | }
18 |
--------------------------------------------------------------------------------