├── .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 | # ![RealWorld Example App](project-logo.png) 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 | ![Data Model](./model/arrows.svg) 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 | -------------------------------------------------------------------------------- /model/arrows.svg: -------------------------------------------------------------------------------- 1 | POSTEDHAS_TAGCOMMENTEDFORUserArticleTagCommentid: username: password: image: bio: createdAt: updatedAt: uuidStringStringStringStringDateTimeDateTimeid: title: body: uuidStringStringdescription: createdAt: updatedAt: StringDateTimeDateTimeid: slug: name: createdAt: updatedAt: uuidStringStringDateTimeDateTimeid: body: createdAt: uuidStringDateTimeupdatedAt: DateTime -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------