├── .dockerignore ├── .env.template ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── api-test ├── Conduit.postman_collection.json ├── README.md ├── openapi.yml └── run-api-test.sh ├── docker-compose.yml ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── project-logo.png ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.interface.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── dto │ │ ├── login.dto.ts │ │ └── register.dto.ts │ ├── jwt.strategy.ts │ └── local.strategy.ts ├── config.ts ├── main.ts ├── user │ ├── user.controller.spec.ts │ ├── user.controller.ts │ ├── user.entity.ts │ ├── user.module.ts │ ├── user.service.spec.ts │ └── user.service.ts ├── utils.spec.ts └── utils.ts ├── test ├── .eslintrc.json ├── app.e2e-spec.ts ├── auth.e2e-spec.ts ├── jest-e2e.json └── orm-config.ts ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # App 2 | NEST_SECRET = change-me 3 | 4 | # Swagger 5 | SWAGGER_ENABLE = true 6 | 7 | # Database 8 | TYPEORM_CONNECTION = postgres 9 | TYPEORM_HOST = localhost 10 | TYPEORM_USERNAME = realworld 11 | TYPEORM_PASSWORD = 123456 12 | TYPEORM_DATABASE = nestjs_realworld 13 | TYPEORM_PORT = 5432 14 | TYPEORM_SYNCHRONIZE = true 15 | TYPEORM_LOGGING = true 16 | TYPEORM_ENTITIES = dist/**/*.entity.js 17 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | CI: true 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: pnpm/action-setup@v2 19 | with: 20 | version: 8 21 | run_install: false 22 | 23 | - name: Use Node.js 18.x 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 18.x 27 | cache: 'pnpm' 28 | 29 | - name: Install dependencies 30 | run: pnpm install 31 | 32 | - name: Lint 33 | run: pnpm lint 34 | 35 | - name: Test 36 | run: pnpm test:cov 37 | 38 | - name: collect coverage artifacts 39 | uses: codecov/codecov-action@v1 40 | with: 41 | file: ./coverage/lcov.info 42 | 43 | # - uses: azure/docker-login@v1 44 | # with: 45 | # username: ${{ secrets.DOCKER_USERNAME }} 46 | # password: ${{ secrets.DOCKER_PASSWORD }} 47 | # 48 | # - name: Build docker image 49 | # run: pnpm build:docker 50 | 51 | e2e: 52 | runs-on: ubuntu-latest 53 | 54 | env: 55 | CI: true 56 | 57 | services: 58 | postgres: 59 | image: postgres:11 60 | env: 61 | POSTGRES_USER: realworld 62 | POSTGRES_PASSWORD: 123456 63 | POSTGRES_DB: nestjs_test 64 | ports: 65 | - 5432:5432 66 | options: >- 67 | --health-cmd pg_isready 68 | --health-interval 10s 69 | --health-timeout 5s 70 | --health-retries 5 71 | 72 | steps: 73 | - uses: actions/checkout@v3 74 | 75 | - uses: pnpm/action-setup@v2 76 | with: 77 | version: 8 78 | run_install: false 79 | 80 | - name: Use Node.js 18.x 81 | uses: actions/setup-node@v3 82 | with: 83 | node-version: 18.x 84 | cache: 'pnpm' 85 | 86 | - name: Install dependencies 87 | run: pnpm install 88 | 89 | - name: Run tests 90 | run: pnpm test:e2e 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Environemnt variables 6 | .env 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS dependencies 2 | WORKDIR /usr/src/app 3 | COPY package.json pnpm-lock.yaml ./ 4 | RUN pnpm install --prod 5 | 6 | FROM node:18-alpine 7 | WORKDIR /usr/src/app 8 | COPY package.json dist ./ 9 | COPY --from=dependencies /usr/src/app/node_modules ./node_modules 10 | EXPOSE 3000 11 | CMD [ "node", "dist/main" ] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 mutoe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Nestjs Realworld Example App](project-logo.png) 2 | 3 | > ### NestJS codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) API spec. 4 | 5 | 6 | ---------- 7 | 8 | # Getting started 9 | 10 | ```bash 11 | pnpm install 12 | cp .env.template .env 13 | docker compose up 14 | ``` 15 | 16 | - Open `http://localhost:3000/api/articles` in your favourite browser` 17 | - Open `http://localhost:3000/docs` to view swagger API document 18 | 19 | ---------- 20 | 21 | ## API Specification 22 | 23 | This application adheres to the api specifications set by the [Thinkster](https://github.com/gothinkster) team. This helps mix and match any backend with any other frontend without conflicts. 24 | 25 | > [Full API Spec](https://github.com/gothinkster/realworld/tree/master/api) 26 | 27 | More information regarding the project can be found here https://github.com/gothinkster/realworld 28 | 29 | 30 | ---------- 31 | 32 | # Authentication 33 | 34 | This applications uses JSON Web Token (JWT) to handle authentication. The token is passed with each request using the `Authorization` header with `Token` scheme. The JWT authentication middleware handles the validation and authentication of the token. Please check the following sources to learn more about JWT. 35 | -------------------------------------------------------------------------------- /api-test/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": ["{{APIURL}}"], 57 | "path": ["users"] 58 | } 59 | }, 60 | "response": [] 61 | }, 62 | { 63 | "name": "Login", 64 | "event": [ 65 | { 66 | "listen": "test", 67 | "script": { 68 | "type": "text/javascript", 69 | "exec": [ 70 | "var responseJSON = JSON.parse(responseBody);", 71 | "", 72 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 73 | "", 74 | "var user = responseJSON.user || {};", 75 | "", 76 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 77 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 78 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 79 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 80 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 81 | "" 82 | ] 83 | } 84 | } 85 | ], 86 | "request": { 87 | "method": "POST", 88 | "header": [ 89 | { 90 | "key": "Content-Type", 91 | "value": "application/json" 92 | }, 93 | { 94 | "key": "X-Requested-With", 95 | "value": "XMLHttpRequest" 96 | } 97 | ], 98 | "body": { 99 | "mode": "raw", 100 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" 101 | }, 102 | "url": { 103 | "raw": "{{APIURL}}/users/login", 104 | "host": ["{{APIURL}}"], 105 | "path": ["users", "login"] 106 | } 107 | }, 108 | "response": [] 109 | }, 110 | { 111 | "name": "Login and Remember Token", 112 | "event": [ 113 | { 114 | "listen": "test", 115 | "script": { 116 | "id": "a7674032-bf09-4ae7-8224-4afa2fb1a9f9", 117 | "type": "text/javascript", 118 | "exec": [ 119 | "var responseJSON = JSON.parse(responseBody);", 120 | "", 121 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 122 | "", 123 | "var user = responseJSON.user || {};", 124 | "", 125 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 126 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 127 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 128 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 129 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 130 | "", 131 | "if(tests['User has \"token\" property']){", 132 | " pm.globals.set('token', user.token);", 133 | "}", 134 | "", 135 | "tests['Global variable \"token\" has been set'] = pm.globals.get('token') === user.token;", 136 | "" 137 | ] 138 | } 139 | } 140 | ], 141 | "request": { 142 | "method": "POST", 143 | "header": [ 144 | { 145 | "key": "Content-Type", 146 | "value": "application/json" 147 | }, 148 | { 149 | "key": "X-Requested-With", 150 | "value": "XMLHttpRequest" 151 | } 152 | ], 153 | "body": { 154 | "mode": "raw", 155 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" 156 | }, 157 | "url": { 158 | "raw": "{{APIURL}}/users/login", 159 | "host": ["{{APIURL}}"], 160 | "path": ["users", "login"] 161 | } 162 | }, 163 | "response": [] 164 | }, 165 | { 166 | "name": "Current User", 167 | "event": [ 168 | { 169 | "listen": "test", 170 | "script": { 171 | "type": "text/javascript", 172 | "exec": [ 173 | "var responseJSON = JSON.parse(responseBody);", 174 | "", 175 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 176 | "", 177 | "var user = responseJSON.user || {};", 178 | "", 179 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 180 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 181 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 182 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 183 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 184 | "" 185 | ] 186 | } 187 | } 188 | ], 189 | "request": { 190 | "method": "GET", 191 | "header": [ 192 | { 193 | "key": "Content-Type", 194 | "value": "application/json" 195 | }, 196 | { 197 | "key": "X-Requested-With", 198 | "value": "XMLHttpRequest" 199 | }, 200 | { 201 | "key": "Authorization", 202 | "value": "Token {{token}}" 203 | } 204 | ], 205 | "body": { 206 | "mode": "raw", 207 | "raw": "" 208 | }, 209 | "url": { 210 | "raw": "{{APIURL}}/user", 211 | "host": ["{{APIURL}}"], 212 | "path": ["user"] 213 | } 214 | }, 215 | "response": [] 216 | }, 217 | { 218 | "name": "Update User", 219 | "event": [ 220 | { 221 | "listen": "test", 222 | "script": { 223 | "type": "text/javascript", 224 | "exec": [ 225 | "var responseJSON = JSON.parse(responseBody);", 226 | "", 227 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 228 | "", 229 | "var user = responseJSON.user || {};", 230 | "", 231 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 232 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 233 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 234 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 235 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 236 | "" 237 | ] 238 | } 239 | } 240 | ], 241 | "request": { 242 | "method": "PUT", 243 | "header": [ 244 | { 245 | "key": "Content-Type", 246 | "value": "application/json" 247 | }, 248 | { 249 | "key": "X-Requested-With", 250 | "value": "XMLHttpRequest" 251 | }, 252 | { 253 | "key": "Authorization", 254 | "value": "Token {{token}}" 255 | } 256 | ], 257 | "body": { 258 | "mode": "raw", 259 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}" 260 | }, 261 | "url": { 262 | "raw": "{{APIURL}}/user", 263 | "host": ["{{APIURL}}"], 264 | "path": ["user"] 265 | } 266 | }, 267 | "response": [] 268 | } 269 | ] 270 | }, 271 | { 272 | "name": "Articles", 273 | "item": [ 274 | { 275 | "name": "All Articles", 276 | "event": [ 277 | { 278 | "listen": "test", 279 | "script": { 280 | "type": "text/javascript", 281 | "exec": [ 282 | "var is200Response = responseCode.code === 200;", 283 | "", 284 | "tests['Response code is 200 OK'] = is200Response;", 285 | "", 286 | "if(is200Response){", 287 | " var responseJSON = JSON.parse(responseBody);", 288 | "", 289 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 290 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 291 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 292 | "", 293 | " if(responseJSON.articles.length){", 294 | " var article = responseJSON.articles[0];", 295 | "", 296 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 297 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 298 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 299 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 300 | " 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);", 301 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 302 | " 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);", 303 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 304 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 305 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 306 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 307 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 308 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 309 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 310 | " } else {", 311 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 312 | " }", 313 | "}", 314 | "" 315 | ] 316 | } 317 | } 318 | ], 319 | "request": { 320 | "method": "GET", 321 | "header": [ 322 | { 323 | "key": "Content-Type", 324 | "value": "application/json" 325 | }, 326 | { 327 | "key": "X-Requested-With", 328 | "value": "XMLHttpRequest" 329 | } 330 | ], 331 | "body": { 332 | "mode": "raw", 333 | "raw": "" 334 | }, 335 | "url": { 336 | "raw": "{{APIURL}}/articles", 337 | "host": ["{{APIURL}}"], 338 | "path": ["articles"] 339 | } 340 | }, 341 | "response": [] 342 | }, 343 | { 344 | "name": "Articles by Author", 345 | "event": [ 346 | { 347 | "listen": "test", 348 | "script": { 349 | "type": "text/javascript", 350 | "exec": [ 351 | "var is200Response = responseCode.code === 200;", 352 | "", 353 | "tests['Response code is 200 OK'] = is200Response;", 354 | "", 355 | "if(is200Response){", 356 | " var responseJSON = JSON.parse(responseBody);", 357 | "", 358 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 359 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 360 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 361 | "", 362 | " if(responseJSON.articles.length){", 363 | " var article = responseJSON.articles[0];", 364 | "", 365 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 366 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 367 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 368 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 369 | " 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);", 370 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 371 | " 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);", 372 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 373 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 374 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 375 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 376 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 377 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 378 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 379 | " } else {", 380 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 381 | " }", 382 | "}", 383 | "" 384 | ] 385 | } 386 | } 387 | ], 388 | "request": { 389 | "method": "GET", 390 | "header": [ 391 | { 392 | "key": "Content-Type", 393 | "value": "application/json" 394 | }, 395 | { 396 | "key": "X-Requested-With", 397 | "value": "XMLHttpRequest" 398 | } 399 | ], 400 | "body": { 401 | "mode": "raw", 402 | "raw": "" 403 | }, 404 | "url": { 405 | "raw": "{{APIURL}}/articles?author=johnjacob", 406 | "host": ["{{APIURL}}"], 407 | "path": ["articles"], 408 | "query": [ 409 | { 410 | "key": "author", 411 | "value": "johnjacob" 412 | } 413 | ] 414 | } 415 | }, 416 | "response": [] 417 | }, 418 | { 419 | "name": "Articles Favorited by Username", 420 | "event": [ 421 | { 422 | "listen": "test", 423 | "script": { 424 | "type": "text/javascript", 425 | "exec": [ 426 | "var is200Response = responseCode.code === 200;", 427 | "", 428 | "tests['Response code is 200 OK'] = is200Response;", 429 | "", 430 | "if(is200Response){", 431 | " var responseJSON = JSON.parse(responseBody);", 432 | " ", 433 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 434 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 435 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 436 | "", 437 | " if(responseJSON.articles.length){", 438 | " var article = responseJSON.articles[0];", 439 | "", 440 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 441 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 442 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 443 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 444 | " 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);", 445 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 446 | " 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);", 447 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 448 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 449 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 450 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 451 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 452 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 453 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 454 | " } else {", 455 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 456 | " }", 457 | "}", 458 | "" 459 | ] 460 | } 461 | } 462 | ], 463 | "request": { 464 | "method": "GET", 465 | "header": [ 466 | { 467 | "key": "Content-Type", 468 | "value": "application/json" 469 | }, 470 | { 471 | "key": "X-Requested-With", 472 | "value": "XMLHttpRequest" 473 | } 474 | ], 475 | "body": { 476 | "mode": "raw", 477 | "raw": "" 478 | }, 479 | "url": { 480 | "raw": "{{APIURL}}/articles?favorited={{USERNAME}}", 481 | "host": ["{{APIURL}}"], 482 | "path": ["articles"], 483 | "query": [ 484 | { 485 | "key": "favorited", 486 | "value": "{{USERNAME}}" 487 | } 488 | ] 489 | } 490 | }, 491 | "response": [] 492 | }, 493 | { 494 | "name": "Articles by Tag", 495 | "event": [ 496 | { 497 | "listen": "test", 498 | "script": { 499 | "type": "text/javascript", 500 | "exec": [ 501 | "var is200Response = responseCode.code === 200;", 502 | "", 503 | "tests['Response code is 200 OK'] = is200Response;", 504 | "", 505 | "if(is200Response){", 506 | " var responseJSON = JSON.parse(responseBody);", 507 | "", 508 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 509 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 510 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 511 | "", 512 | " if(responseJSON.articles.length){", 513 | " var article = responseJSON.articles[0];", 514 | "", 515 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 516 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 517 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 518 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 519 | " 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);", 520 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 521 | " 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);", 522 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 523 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 524 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 525 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 526 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 527 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 528 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 529 | " } else {", 530 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 531 | " }", 532 | "}", 533 | "" 534 | ] 535 | } 536 | } 537 | ], 538 | "request": { 539 | "method": "GET", 540 | "header": [ 541 | { 542 | "key": "Content-Type", 543 | "value": "application/json" 544 | }, 545 | { 546 | "key": "X-Requested-With", 547 | "value": "XMLHttpRequest" 548 | } 549 | ], 550 | "body": { 551 | "mode": "raw", 552 | "raw": "" 553 | }, 554 | "url": { 555 | "raw": "{{APIURL}}/articles?tag=dragons", 556 | "host": ["{{APIURL}}"], 557 | "path": ["articles"], 558 | "query": [ 559 | { 560 | "key": "tag", 561 | "value": "dragons" 562 | } 563 | ] 564 | } 565 | }, 566 | "response": [] 567 | } 568 | ] 569 | }, 570 | { 571 | "name": "Articles, Favorite, Comments", 572 | "item": [ 573 | { 574 | "name": "Create Article", 575 | "event": [ 576 | { 577 | "listen": "test", 578 | "script": { 579 | "id": "e711dbf8-8065-4ba8-8b74-f1639a7d8208", 580 | "type": "text/javascript", 581 | "exec": [ 582 | "var responseJSON = JSON.parse(responseBody);", 583 | "", 584 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 585 | "", 586 | "var article = responseJSON.article || {};", 587 | "", 588 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 589 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 590 | "pm.globals.set('slug', article.slug);", 591 | "", 592 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 593 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 594 | "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);", 595 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 596 | "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);", 597 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 598 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 599 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 600 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 601 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 602 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 603 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 604 | "" 605 | ] 606 | } 607 | } 608 | ], 609 | "request": { 610 | "method": "POST", 611 | "header": [ 612 | { 613 | "key": "Content-Type", 614 | "value": "application/json" 615 | }, 616 | { 617 | "key": "X-Requested-With", 618 | "value": "XMLHttpRequest" 619 | }, 620 | { 621 | "key": "Authorization", 622 | "value": "Token {{token}}" 623 | } 624 | ], 625 | "body": { 626 | "mode": "raw", 627 | "raw": "{\"article\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"training\", \"dragons\"]}}" 628 | }, 629 | "url": { 630 | "raw": "{{APIURL}}/articles", 631 | "host": ["{{APIURL}}"], 632 | "path": ["articles"] 633 | } 634 | }, 635 | "response": [] 636 | }, 637 | { 638 | "name": "Feed", 639 | "event": [ 640 | { 641 | "listen": "test", 642 | "script": { 643 | "type": "text/javascript", 644 | "exec": [ 645 | "var is200Response = responseCode.code === 200;", 646 | "", 647 | "tests['Response code is 200 OK'] = is200Response;", 648 | "", 649 | "if(is200Response){", 650 | " var responseJSON = JSON.parse(responseBody);", 651 | "", 652 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 653 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 654 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 655 | "", 656 | " if(responseJSON.articles.length){", 657 | " var article = responseJSON.articles[0];", 658 | "", 659 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 660 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 661 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 662 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 663 | " 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);", 664 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 665 | " 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);", 666 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 667 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 668 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 669 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 670 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 671 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 672 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 673 | " } else {", 674 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 675 | " }", 676 | "}", 677 | "" 678 | ] 679 | } 680 | } 681 | ], 682 | "request": { 683 | "method": "GET", 684 | "header": [ 685 | { 686 | "key": "Content-Type", 687 | "value": "application/json" 688 | }, 689 | { 690 | "key": "X-Requested-With", 691 | "value": "XMLHttpRequest" 692 | }, 693 | { 694 | "key": "Authorization", 695 | "value": "Token {{token}}" 696 | } 697 | ], 698 | "body": { 699 | "mode": "raw", 700 | "raw": "" 701 | }, 702 | "url": { 703 | "raw": "{{APIURL}}/articles/feed", 704 | "host": ["{{APIURL}}"], 705 | "path": ["articles", "feed"] 706 | } 707 | }, 708 | "response": [] 709 | }, 710 | { 711 | "name": "All Articles", 712 | "event": [ 713 | { 714 | "listen": "test", 715 | "script": { 716 | "type": "text/javascript", 717 | "exec": [ 718 | "var is200Response = responseCode.code === 200;", 719 | "", 720 | "tests['Response code is 200 OK'] = is200Response;", 721 | "", 722 | "if(is200Response){", 723 | " var responseJSON = JSON.parse(responseBody);", 724 | "", 725 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 726 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 727 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 728 | "", 729 | " if(responseJSON.articles.length){", 730 | " var article = responseJSON.articles[0];", 731 | "", 732 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 733 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 734 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 735 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 736 | " 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);", 737 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 738 | " 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);", 739 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 740 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 741 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 742 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 743 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 744 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 745 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 746 | " } else {", 747 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 748 | " }", 749 | "}", 750 | "" 751 | ] 752 | } 753 | } 754 | ], 755 | "request": { 756 | "method": "GET", 757 | "header": [ 758 | { 759 | "key": "Content-Type", 760 | "value": "application/json" 761 | }, 762 | { 763 | "key": "X-Requested-With", 764 | "value": "XMLHttpRequest" 765 | }, 766 | { 767 | "key": "Authorization", 768 | "value": "Token {{token}}" 769 | } 770 | ], 771 | "body": { 772 | "mode": "raw", 773 | "raw": "" 774 | }, 775 | "url": { 776 | "raw": "{{APIURL}}/articles", 777 | "host": ["{{APIURL}}"], 778 | "path": ["articles"] 779 | } 780 | }, 781 | "response": [] 782 | }, 783 | { 784 | "name": "All Articles with auth", 785 | "event": [ 786 | { 787 | "listen": "test", 788 | "script": { 789 | "type": "text/javascript", 790 | "exec": [ 791 | "var is200Response = responseCode.code === 200;", 792 | "", 793 | "tests['Response code is 200 OK'] = is200Response;", 794 | "", 795 | "if(is200Response){", 796 | " var responseJSON = JSON.parse(responseBody);", 797 | "", 798 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 799 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 800 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 801 | "", 802 | " if(responseJSON.articles.length){", 803 | " var article = responseJSON.articles[0];", 804 | "", 805 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 806 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 807 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 808 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 809 | " 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);", 810 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 811 | " 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);", 812 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 813 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 814 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 815 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 816 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 817 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 818 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 819 | " } else {", 820 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 821 | " }", 822 | "}", 823 | "" 824 | ] 825 | } 826 | } 827 | ], 828 | "request": { 829 | "method": "GET", 830 | "header": [ 831 | { 832 | "key": "Content-Type", 833 | "value": "application/json" 834 | }, 835 | { 836 | "key": "X-Requested-With", 837 | "value": "XMLHttpRequest" 838 | }, 839 | { 840 | "key": "Authorization", 841 | "value": "Token {{token}}" 842 | } 843 | ], 844 | "body": { 845 | "mode": "raw", 846 | "raw": "" 847 | }, 848 | "url": { 849 | "raw": "{{APIURL}}/articles", 850 | "host": ["{{APIURL}}"], 851 | "path": ["articles"] 852 | } 853 | }, 854 | "response": [] 855 | }, 856 | { 857 | "name": "Articles by Author", 858 | "event": [ 859 | { 860 | "listen": "test", 861 | "script": { 862 | "type": "text/javascript", 863 | "exec": [ 864 | "var is200Response = responseCode.code === 200;", 865 | "", 866 | "tests['Response code is 200 OK'] = is200Response;", 867 | "", 868 | "if(is200Response){", 869 | " var responseJSON = JSON.parse(responseBody);", 870 | "", 871 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 872 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 873 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 874 | "", 875 | " if(responseJSON.articles.length){", 876 | " var article = responseJSON.articles[0];", 877 | "", 878 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 879 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 880 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 881 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 882 | " 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);", 883 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 884 | " 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);", 885 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 886 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 887 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 888 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 889 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 890 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 891 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 892 | " } else {", 893 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 894 | " }", 895 | "}", 896 | "" 897 | ] 898 | } 899 | } 900 | ], 901 | "request": { 902 | "method": "GET", 903 | "header": [ 904 | { 905 | "key": "Content-Type", 906 | "value": "application/json" 907 | }, 908 | { 909 | "key": "X-Requested-With", 910 | "value": "XMLHttpRequest" 911 | }, 912 | { 913 | "key": "Authorization", 914 | "value": "Token {{token}}" 915 | } 916 | ], 917 | "body": { 918 | "mode": "raw", 919 | "raw": "" 920 | }, 921 | "url": { 922 | "raw": "{{APIURL}}/articles?author={{USERNAME}}", 923 | "host": ["{{APIURL}}"], 924 | "path": ["articles"], 925 | "query": [ 926 | { 927 | "key": "author", 928 | "value": "{{USERNAME}}" 929 | } 930 | ] 931 | } 932 | }, 933 | "response": [] 934 | }, 935 | { 936 | "name": "Articles by Author with auth", 937 | "event": [ 938 | { 939 | "listen": "test", 940 | "script": { 941 | "type": "text/javascript", 942 | "exec": [ 943 | "var is200Response = responseCode.code === 200;", 944 | "", 945 | "tests['Response code is 200 OK'] = is200Response;", 946 | "", 947 | "if(is200Response){", 948 | " var responseJSON = JSON.parse(responseBody);", 949 | "", 950 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 951 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 952 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 953 | "", 954 | " if(responseJSON.articles.length){", 955 | " var article = responseJSON.articles[0];", 956 | "", 957 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 958 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 959 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 960 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 961 | " 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);", 962 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 963 | " 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);", 964 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 965 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 966 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 967 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 968 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 969 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 970 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 971 | " } else {", 972 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", 973 | " }", 974 | "}", 975 | "" 976 | ] 977 | } 978 | } 979 | ], 980 | "request": { 981 | "method": "GET", 982 | "header": [ 983 | { 984 | "key": "Content-Type", 985 | "value": "application/json" 986 | }, 987 | { 988 | "key": "X-Requested-With", 989 | "value": "XMLHttpRequest" 990 | }, 991 | { 992 | "key": "Authorization", 993 | "value": "Token {{token}}" 994 | } 995 | ], 996 | "body": { 997 | "mode": "raw", 998 | "raw": "" 999 | }, 1000 | "url": { 1001 | "raw": "{{APIURL}}/articles?author={{USERNAME}}", 1002 | "host": ["{{APIURL}}"], 1003 | "path": ["articles"], 1004 | "query": [ 1005 | { 1006 | "key": "author", 1007 | "value": "{{USERNAME}}" 1008 | } 1009 | ] 1010 | } 1011 | }, 1012 | "response": [] 1013 | }, 1014 | { 1015 | "name": "Single Article by slug", 1016 | "event": [ 1017 | { 1018 | "listen": "test", 1019 | "script": { 1020 | "type": "text/javascript", 1021 | "exec": [ 1022 | "var responseJSON = JSON.parse(responseBody);", 1023 | "", 1024 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1025 | "", 1026 | "var article = responseJSON.article || {};", 1027 | "", 1028 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1029 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1030 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1031 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1032 | "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);", 1033 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1034 | "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);", 1035 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1036 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1037 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1038 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1039 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1040 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1041 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1042 | "" 1043 | ] 1044 | } 1045 | } 1046 | ], 1047 | "request": { 1048 | "method": "GET", 1049 | "header": [ 1050 | { 1051 | "key": "Content-Type", 1052 | "value": "application/json" 1053 | }, 1054 | { 1055 | "key": "X-Requested-With", 1056 | "value": "XMLHttpRequest" 1057 | }, 1058 | { 1059 | "key": "Authorization", 1060 | "value": "Token {{token}}" 1061 | } 1062 | ], 1063 | "body": { 1064 | "mode": "raw", 1065 | "raw": "" 1066 | }, 1067 | "url": { 1068 | "raw": "{{APIURL}}/articles/{{slug}}", 1069 | "host": ["{{APIURL}}"], 1070 | "path": ["articles", "{{slug}}"] 1071 | } 1072 | }, 1073 | "response": [] 1074 | }, 1075 | { 1076 | "name": "Articles by Tag", 1077 | "event": [ 1078 | { 1079 | "listen": "test", 1080 | "script": { 1081 | "type": "text/javascript", 1082 | "exec": [ 1083 | "var is200Response = responseCode.code === 200;", 1084 | "", 1085 | "tests['Response code is 200 OK'] = is200Response;", 1086 | "", 1087 | "if(is200Response){", 1088 | " var responseJSON = JSON.parse(responseBody);", 1089 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 1090 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 1091 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 1092 | " var article = responseJSON.articles[0];", 1093 | " tests['An article was returned'] = article !== undefined;", 1094 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1095 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1096 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1097 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1098 | " 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);", 1099 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1100 | " 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);", 1101 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1102 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1103 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1104 | " tests['The first tag is dragons'] = article.tagList[0] === 'dragons';", 1105 | " tests['The second tag is training'] = article.tagList[1] === 'training';", 1106 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1107 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1108 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1109 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1110 | "}", 1111 | "" 1112 | ] 1113 | } 1114 | } 1115 | ], 1116 | "request": { 1117 | "method": "GET", 1118 | "header": [ 1119 | { 1120 | "key": "Content-Type", 1121 | "value": "application/json" 1122 | }, 1123 | { 1124 | "key": "X-Requested-With", 1125 | "value": "XMLHttpRequest" 1126 | }, 1127 | { 1128 | "key": "Authorization", 1129 | "value": "Token {{token}}" 1130 | } 1131 | ], 1132 | "body": { 1133 | "mode": "raw", 1134 | "raw": "" 1135 | }, 1136 | "url": { 1137 | "raw": "{{APIURL}}/articles?tag=dragons", 1138 | "host": ["{{APIURL}}"], 1139 | "path": ["articles"], 1140 | "query": [ 1141 | { 1142 | "key": "tag", 1143 | "value": "dragons" 1144 | } 1145 | ] 1146 | } 1147 | }, 1148 | "response": [] 1149 | }, 1150 | { 1151 | "name": "Update Article", 1152 | "event": [ 1153 | { 1154 | "listen": "test", 1155 | "script": { 1156 | "type": "text/javascript", 1157 | "exec": [ 1158 | "if (!(environment.isIntegrationTest)) {", 1159 | "var responseJSON = JSON.parse(responseBody);", 1160 | "", 1161 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1162 | "", 1163 | "var article = responseJSON.article || {};", 1164 | "", 1165 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1166 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1167 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1168 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1169 | "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);", 1170 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1171 | "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);", 1172 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1173 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1174 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1175 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1176 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1177 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1178 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1179 | "}", 1180 | "" 1181 | ] 1182 | } 1183 | } 1184 | ], 1185 | "request": { 1186 | "method": "PUT", 1187 | "header": [ 1188 | { 1189 | "key": "Content-Type", 1190 | "value": "application/json" 1191 | }, 1192 | { 1193 | "key": "X-Requested-With", 1194 | "value": "XMLHttpRequest" 1195 | }, 1196 | { 1197 | "key": "Authorization", 1198 | "value": "Token {{token}}" 1199 | } 1200 | ], 1201 | "body": { 1202 | "mode": "raw", 1203 | "raw": "{\"article\":{\"body\":\"With two hands\"}}" 1204 | }, 1205 | "url": { 1206 | "raw": "{{APIURL}}/articles/{{slug}}", 1207 | "host": ["{{APIURL}}"], 1208 | "path": ["articles", "{{slug}}"] 1209 | } 1210 | }, 1211 | "response": [] 1212 | }, 1213 | { 1214 | "name": "Favorite Article", 1215 | "event": [ 1216 | { 1217 | "listen": "test", 1218 | "script": { 1219 | "type": "text/javascript", 1220 | "exec": [ 1221 | "var responseJSON = JSON.parse(responseBody);", 1222 | "", 1223 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1224 | "", 1225 | "var article = responseJSON.article || {};", 1226 | "", 1227 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1228 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1229 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1230 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1231 | "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);", 1232 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1233 | "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);", 1234 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1235 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1236 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1237 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1238 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1239 | "tests[\"Article's 'favorited' property is true\"] = article.favorited === true;", 1240 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1241 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1242 | "tests[\"Article's 'favoritesCount' property is greater than 0\"] = article.favoritesCount > 0;", 1243 | "" 1244 | ] 1245 | } 1246 | } 1247 | ], 1248 | "request": { 1249 | "method": "POST", 1250 | "header": [ 1251 | { 1252 | "key": "Content-Type", 1253 | "value": "application/json" 1254 | }, 1255 | { 1256 | "key": "X-Requested-With", 1257 | "value": "XMLHttpRequest" 1258 | }, 1259 | { 1260 | "key": "Authorization", 1261 | "value": "Token {{token}}" 1262 | } 1263 | ], 1264 | "body": { 1265 | "mode": "raw", 1266 | "raw": "" 1267 | }, 1268 | "url": { 1269 | "raw": "{{APIURL}}/articles/{{slug}}/favorite", 1270 | "host": ["{{APIURL}}"], 1271 | "path": ["articles", "{{slug}}", "favorite"] 1272 | } 1273 | }, 1274 | "response": [] 1275 | }, 1276 | { 1277 | "name": "Articles Favorited by Username", 1278 | "event": [ 1279 | { 1280 | "listen": "test", 1281 | "script": { 1282 | "type": "text/javascript", 1283 | "exec": [ 1284 | "var is200Response = responseCode.code === 200;", 1285 | "", 1286 | "tests['Response code is 200 OK'] = is200Response;", 1287 | "", 1288 | "if(is200Response){", 1289 | " var responseJSON = JSON.parse(responseBody);", 1290 | " article = responseJSON.articles[0];", 1291 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 1292 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 1293 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 1294 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1295 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1296 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1297 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1298 | " 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);", 1299 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1300 | " 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);", 1301 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1302 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1303 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1304 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1305 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1306 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1307 | " tests['favoritesCount is 1'] = article.favoritesCount === 1;", 1308 | "}", 1309 | "" 1310 | ] 1311 | } 1312 | } 1313 | ], 1314 | "request": { 1315 | "method": "GET", 1316 | "header": [ 1317 | { 1318 | "key": "Content-Type", 1319 | "value": "application/json" 1320 | }, 1321 | { 1322 | "key": "X-Requested-With", 1323 | "value": "XMLHttpRequest" 1324 | }, 1325 | { 1326 | "key": "Authorization", 1327 | "value": "Token {{token}}" 1328 | } 1329 | ], 1330 | "body": { 1331 | "mode": "raw", 1332 | "raw": "" 1333 | }, 1334 | "url": { 1335 | "raw": "{{APIURL}}/articles?favorited={{USERNAME}}", 1336 | "host": ["{{APIURL}}"], 1337 | "path": ["articles"], 1338 | "query": [ 1339 | { 1340 | "key": "favorited", 1341 | "value": "{{USERNAME}}" 1342 | } 1343 | ] 1344 | } 1345 | }, 1346 | "response": [] 1347 | }, 1348 | { 1349 | "name": "Articles Favorited by Username with auth", 1350 | "event": [ 1351 | { 1352 | "listen": "test", 1353 | "script": { 1354 | "type": "text/javascript", 1355 | "exec": [ 1356 | "var is200Response = responseCode.code === 200;", 1357 | "", 1358 | "tests['Response code is 200 OK'] = is200Response;", 1359 | "", 1360 | "if(is200Response){", 1361 | " var responseJSON = JSON.parse(responseBody);", 1362 | " article = responseJSON.articles[0];", 1363 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", 1364 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", 1365 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", 1366 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1367 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1368 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1369 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1370 | " 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);", 1371 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1372 | " 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);", 1373 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1374 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1375 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1376 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1377 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1378 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1379 | " tests['favoritesCount is 1'] = article.favoritesCount === 1;", 1380 | "}", 1381 | "" 1382 | ] 1383 | } 1384 | } 1385 | ], 1386 | "request": { 1387 | "method": "GET", 1388 | "header": [ 1389 | { 1390 | "key": "Content-Type", 1391 | "value": "application/json" 1392 | }, 1393 | { 1394 | "key": "X-Requested-With", 1395 | "value": "XMLHttpRequest" 1396 | }, 1397 | { 1398 | "key": "Authorization", 1399 | "value": "Token {{token}}" 1400 | } 1401 | ], 1402 | "body": { 1403 | "mode": "raw", 1404 | "raw": "" 1405 | }, 1406 | "url": { 1407 | "raw": "{{APIURL}}/articles?favorited={{USERNAME}}", 1408 | "host": ["{{APIURL}}"], 1409 | "path": ["articles"], 1410 | "query": [ 1411 | { 1412 | "key": "favorited", 1413 | "value": "{{USERNAME}}" 1414 | } 1415 | ] 1416 | } 1417 | }, 1418 | "response": [] 1419 | }, 1420 | { 1421 | "name": "Unfavorite Article", 1422 | "event": [ 1423 | { 1424 | "listen": "test", 1425 | "script": { 1426 | "type": "text/javascript", 1427 | "exec": [ 1428 | "var responseJSON = JSON.parse(responseBody);", 1429 | "", 1430 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", 1431 | "", 1432 | "var article = responseJSON.article || {};", 1433 | "", 1434 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", 1435 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", 1436 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", 1437 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", 1438 | "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);", 1439 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", 1440 | "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);", 1441 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", 1442 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", 1443 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", 1444 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", 1445 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", 1446 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", 1447 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", 1448 | "tests[\"Article's \\\"favorited\\\" property is false\"] = article.favorited === false;", 1449 | "" 1450 | ] 1451 | } 1452 | } 1453 | ], 1454 | "request": { 1455 | "method": "DELETE", 1456 | "header": [ 1457 | { 1458 | "key": "Content-Type", 1459 | "value": "application/json" 1460 | }, 1461 | { 1462 | "key": "X-Requested-With", 1463 | "value": "XMLHttpRequest" 1464 | }, 1465 | { 1466 | "key": "Authorization", 1467 | "value": "Token {{token}}" 1468 | } 1469 | ], 1470 | "body": { 1471 | "mode": "raw", 1472 | "raw": "" 1473 | }, 1474 | "url": { 1475 | "raw": "{{APIURL}}/articles/{{slug}}/favorite", 1476 | "host": ["{{APIURL}}"], 1477 | "path": ["articles", "{{slug}}", "favorite"] 1478 | } 1479 | }, 1480 | "response": [] 1481 | }, 1482 | { 1483 | "name": "Create Comment for Article", 1484 | "event": [ 1485 | { 1486 | "listen": "test", 1487 | "script": { 1488 | "id": "9f90c364-cc68-4728-961a-85eb00197d7b", 1489 | "type": "text/javascript", 1490 | "exec": [ 1491 | "var responseJSON = JSON.parse(responseBody);", 1492 | "", 1493 | "tests['Response contains \"comment\" property'] = responseJSON.hasOwnProperty('comment');", 1494 | "", 1495 | "var comment = responseJSON.comment || {};", 1496 | "", 1497 | "tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", 1498 | "pm.globals.set('commentId', comment.id);", 1499 | "", 1500 | "tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", 1501 | "tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", 1502 | "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);", 1503 | "tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", 1504 | "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);", 1505 | "tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", 1506 | "" 1507 | ] 1508 | } 1509 | } 1510 | ], 1511 | "request": { 1512 | "method": "POST", 1513 | "header": [ 1514 | { 1515 | "key": "Content-Type", 1516 | "value": "application/json" 1517 | }, 1518 | { 1519 | "key": "X-Requested-With", 1520 | "value": "XMLHttpRequest" 1521 | }, 1522 | { 1523 | "key": "Authorization", 1524 | "value": "Token {{token}}" 1525 | } 1526 | ], 1527 | "body": { 1528 | "mode": "raw", 1529 | "raw": "{\"comment\":{\"body\":\"Thank you so much!\"}}" 1530 | }, 1531 | "url": { 1532 | "raw": "{{APIURL}}/articles/{{slug}}/comments", 1533 | "host": ["{{APIURL}}"], 1534 | "path": ["articles", "{{slug}}", "comments"] 1535 | } 1536 | }, 1537 | "response": [] 1538 | }, 1539 | { 1540 | "name": "All Comments for Article", 1541 | "event": [ 1542 | { 1543 | "listen": "test", 1544 | "script": { 1545 | "type": "text/javascript", 1546 | "exec": [ 1547 | "var is200Response = responseCode.code === 200", 1548 | "", 1549 | "tests['Response code is 200 OK'] = is200Response;", 1550 | "", 1551 | "if(is200Response){", 1552 | " var responseJSON = JSON.parse(responseBody);", 1553 | "", 1554 | " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');", 1555 | "", 1556 | " if(responseJSON.comments.length){", 1557 | " var comment = responseJSON.comments[0];", 1558 | "", 1559 | " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", 1560 | " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", 1561 | " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", 1562 | " 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);", 1563 | " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", 1564 | " 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);", 1565 | " tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", 1566 | " }", 1567 | "}", 1568 | "" 1569 | ] 1570 | } 1571 | } 1572 | ], 1573 | "request": { 1574 | "method": "GET", 1575 | "header": [ 1576 | { 1577 | "key": "Content-Type", 1578 | "value": "application/json" 1579 | }, 1580 | { 1581 | "key": "X-Requested-With", 1582 | "value": "XMLHttpRequest" 1583 | }, 1584 | { 1585 | "key": "Authorization", 1586 | "value": "Token {{token}}" 1587 | } 1588 | ], 1589 | "body": { 1590 | "mode": "raw", 1591 | "raw": "" 1592 | }, 1593 | "url": { 1594 | "raw": "{{APIURL}}/articles/{{slug}}/comments", 1595 | "host": ["{{APIURL}}"], 1596 | "path": ["articles", "{{slug}}", "comments"] 1597 | } 1598 | }, 1599 | "response": [] 1600 | }, 1601 | { 1602 | "name": "All Comments for Article without login", 1603 | "event": [ 1604 | { 1605 | "listen": "test", 1606 | "script": { 1607 | "type": "text/javascript", 1608 | "exec": [ 1609 | "var is200Response = responseCode.code === 200", 1610 | "", 1611 | "tests['Response code is 200 OK'] = is200Response;", 1612 | "", 1613 | "if(is200Response){", 1614 | " var responseJSON = JSON.parse(responseBody);", 1615 | "", 1616 | " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');", 1617 | "", 1618 | " if(responseJSON.comments.length){", 1619 | " var comment = responseJSON.comments[0];", 1620 | "", 1621 | " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", 1622 | " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", 1623 | " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", 1624 | " 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);", 1625 | " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", 1626 | " 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);", 1627 | " tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", 1628 | " }", 1629 | "}", 1630 | "" 1631 | ] 1632 | } 1633 | } 1634 | ], 1635 | "request": { 1636 | "method": "GET", 1637 | "header": [ 1638 | { 1639 | "key": "Content-Type", 1640 | "value": "application/json" 1641 | }, 1642 | { 1643 | "key": "X-Requested-With", 1644 | "value": "XMLHttpRequest" 1645 | } 1646 | ], 1647 | "body": { 1648 | "mode": "raw", 1649 | "raw": "" 1650 | }, 1651 | "url": { 1652 | "raw": "{{APIURL}}/articles/{{slug}}/comments", 1653 | "host": ["{{APIURL}}"], 1654 | "path": ["articles", "{{slug}}", "comments"] 1655 | } 1656 | }, 1657 | "response": [] 1658 | }, 1659 | { 1660 | "name": "Delete Comment for Article", 1661 | "request": { 1662 | "method": "DELETE", 1663 | "header": [ 1664 | { 1665 | "key": "Content-Type", 1666 | "value": "application/json" 1667 | }, 1668 | { 1669 | "key": "X-Requested-With", 1670 | "value": "XMLHttpRequest" 1671 | }, 1672 | { 1673 | "key": "Authorization", 1674 | "value": "Token {{token}}" 1675 | } 1676 | ], 1677 | "body": { 1678 | "mode": "raw", 1679 | "raw": "" 1680 | }, 1681 | "url": { 1682 | "raw": "{{APIURL}}/articles/{{slug}}/comments/{{commentId}}", 1683 | "host": ["{{APIURL}}"], 1684 | "path": ["articles", "{{slug}}", "comments", "{{commentId}}"] 1685 | } 1686 | }, 1687 | "response": [] 1688 | }, 1689 | { 1690 | "name": "Delete Article", 1691 | "request": { 1692 | "method": "DELETE", 1693 | "header": [ 1694 | { 1695 | "key": "Content-Type", 1696 | "value": "application/json" 1697 | }, 1698 | { 1699 | "key": "X-Requested-With", 1700 | "value": "XMLHttpRequest" 1701 | }, 1702 | { 1703 | "key": "Authorization", 1704 | "value": "Token {{token}}" 1705 | } 1706 | ], 1707 | "body": { 1708 | "mode": "raw", 1709 | "raw": "" 1710 | }, 1711 | "url": { 1712 | "raw": "{{APIURL}}/articles/{{slug}}", 1713 | "host": ["{{APIURL}}"], 1714 | "path": ["articles", "{{slug}}"] 1715 | } 1716 | }, 1717 | "response": [] 1718 | } 1719 | ], 1720 | "event": [ 1721 | { 1722 | "listen": "prerequest", 1723 | "script": { 1724 | "id": "67853a4a-e972-4573-a295-dad12a46a9d7", 1725 | "type": "text/javascript", 1726 | "exec": [""] 1727 | } 1728 | }, 1729 | { 1730 | "listen": "test", 1731 | "script": { 1732 | "id": "3057f989-15e4-484e-b8fa-a041043d0ac0", 1733 | "type": "text/javascript", 1734 | "exec": [""] 1735 | } 1736 | } 1737 | ] 1738 | }, 1739 | { 1740 | "name": "Profiles", 1741 | "item": [ 1742 | { 1743 | "name": "Register Celeb", 1744 | "event": [ 1745 | { 1746 | "listen": "test", 1747 | "script": { 1748 | "type": "text/javascript", 1749 | "exec": [ 1750 | "if (!(environment.isIntegrationTest)) {", 1751 | "var responseJSON = JSON.parse(responseBody);", 1752 | "", 1753 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", 1754 | "", 1755 | "var user = responseJSON.user || {};", 1756 | "", 1757 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');", 1758 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');", 1759 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", 1760 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');", 1761 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');", 1762 | "}", 1763 | "" 1764 | ] 1765 | } 1766 | } 1767 | ], 1768 | "request": { 1769 | "method": "POST", 1770 | "header": [ 1771 | { 1772 | "key": "Content-Type", 1773 | "value": "application/json" 1774 | }, 1775 | { 1776 | "key": "X-Requested-With", 1777 | "value": "XMLHttpRequest" 1778 | } 1779 | ], 1780 | "body": { 1781 | "mode": "raw", 1782 | "raw": "{\"user\":{\"email\":\"celeb_{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"celeb_{{USERNAME}}\"}}" 1783 | }, 1784 | "url": { 1785 | "raw": "{{APIURL}}/users", 1786 | "host": ["{{APIURL}}"], 1787 | "path": ["users"] 1788 | } 1789 | }, 1790 | "response": [] 1791 | }, 1792 | { 1793 | "name": "Profile", 1794 | "event": [ 1795 | { 1796 | "listen": "test", 1797 | "script": { 1798 | "type": "text/javascript", 1799 | "exec": [ 1800 | "if (!(environment.isIntegrationTest)) {", 1801 | "var is200Response = responseCode.code === 200;", 1802 | "", 1803 | "tests['Response code is 200 OK'] = is200Response;", 1804 | "", 1805 | "if(is200Response){", 1806 | " var responseJSON = JSON.parse(responseBody);", 1807 | "", 1808 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", 1809 | " ", 1810 | " var profile = responseJSON.profile || {};", 1811 | " ", 1812 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", 1813 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", 1814 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", 1815 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", 1816 | "}", 1817 | "}", 1818 | "" 1819 | ] 1820 | } 1821 | } 1822 | ], 1823 | "request": { 1824 | "method": "GET", 1825 | "header": [ 1826 | { 1827 | "key": "Content-Type", 1828 | "value": "application/json" 1829 | }, 1830 | { 1831 | "key": "X-Requested-With", 1832 | "value": "XMLHttpRequest" 1833 | }, 1834 | { 1835 | "key": "Authorization", 1836 | "value": "Token {{token}}" 1837 | } 1838 | ], 1839 | "body": { 1840 | "mode": "raw", 1841 | "raw": "" 1842 | }, 1843 | "url": { 1844 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}", 1845 | "host": ["{{APIURL}}"], 1846 | "path": ["profiles", "celeb_{{USERNAME}}"] 1847 | } 1848 | }, 1849 | "response": [] 1850 | }, 1851 | { 1852 | "name": "Follow Profile", 1853 | "event": [ 1854 | { 1855 | "listen": "test", 1856 | "script": { 1857 | "type": "text/javascript", 1858 | "exec": [ 1859 | "if (!(environment.isIntegrationTest)) {", 1860 | "var is200Response = responseCode.code === 200;", 1861 | "", 1862 | "tests['Response code is 200 OK'] = is200Response;", 1863 | "", 1864 | "if(is200Response){", 1865 | " var responseJSON = JSON.parse(responseBody);", 1866 | "", 1867 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", 1868 | " ", 1869 | " var profile = responseJSON.profile || {};", 1870 | " ", 1871 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", 1872 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", 1873 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", 1874 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", 1875 | " tests['Profile\\'s \"following\" property is true'] = profile.following === true;", 1876 | "}", 1877 | "}", 1878 | "" 1879 | ] 1880 | } 1881 | } 1882 | ], 1883 | "request": { 1884 | "method": "POST", 1885 | "header": [ 1886 | { 1887 | "key": "Content-Type", 1888 | "value": "application/json" 1889 | }, 1890 | { 1891 | "key": "X-Requested-With", 1892 | "value": "XMLHttpRequest" 1893 | }, 1894 | { 1895 | "key": "Authorization", 1896 | "value": "Token {{token}}" 1897 | } 1898 | ], 1899 | "body": { 1900 | "mode": "raw", 1901 | "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}" 1902 | }, 1903 | "url": { 1904 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", 1905 | "host": ["{{APIURL}}"], 1906 | "path": ["profiles", "celeb_{{USERNAME}}", "follow"] 1907 | } 1908 | }, 1909 | "response": [] 1910 | }, 1911 | { 1912 | "name": "Unfollow Profile", 1913 | "event": [ 1914 | { 1915 | "listen": "test", 1916 | "script": { 1917 | "type": "text/javascript", 1918 | "exec": [ 1919 | "if (!(environment.isIntegrationTest)) {", 1920 | "var is200Response = responseCode.code === 200;", 1921 | "", 1922 | "tests['Response code is 200 OK'] = is200Response;", 1923 | "", 1924 | "if(is200Response){", 1925 | " var responseJSON = JSON.parse(responseBody);", 1926 | "", 1927 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", 1928 | " ", 1929 | " var profile = responseJSON.profile || {};", 1930 | " ", 1931 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", 1932 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", 1933 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", 1934 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", 1935 | " tests['Profile\\'s \"following\" property is false'] = profile.following === false;", 1936 | "}", 1937 | "}", 1938 | "" 1939 | ] 1940 | } 1941 | } 1942 | ], 1943 | "request": { 1944 | "method": "DELETE", 1945 | "header": [ 1946 | { 1947 | "key": "Content-Type", 1948 | "value": "application/json" 1949 | }, 1950 | { 1951 | "key": "X-Requested-With", 1952 | "value": "XMLHttpRequest" 1953 | }, 1954 | { 1955 | "key": "Authorization", 1956 | "value": "Token {{token}}" 1957 | } 1958 | ], 1959 | "body": { 1960 | "mode": "raw", 1961 | "raw": "" 1962 | }, 1963 | "url": { 1964 | "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", 1965 | "host": ["{{APIURL}}"], 1966 | "path": ["profiles", "celeb_{{USERNAME}}", "follow"] 1967 | } 1968 | }, 1969 | "response": [] 1970 | } 1971 | ] 1972 | }, 1973 | { 1974 | "name": "Tags", 1975 | "item": [ 1976 | { 1977 | "name": "All Tags", 1978 | "event": [ 1979 | { 1980 | "listen": "test", 1981 | "script": { 1982 | "type": "text/javascript", 1983 | "exec": [ 1984 | "var is200Response = responseCode.code === 200;", 1985 | "", 1986 | "tests['Response code is 200 OK'] = is200Response;", 1987 | "", 1988 | "if(is200Response){", 1989 | " var responseJSON = JSON.parse(responseBody);", 1990 | " ", 1991 | " tests['Response contains \"tags\" property'] = responseJSON.hasOwnProperty('tags');", 1992 | " tests['\"tags\" property returned as array'] = Array.isArray(responseJSON.tags);", 1993 | "}", 1994 | "" 1995 | ] 1996 | } 1997 | } 1998 | ], 1999 | "request": { 2000 | "method": "GET", 2001 | "header": [ 2002 | { 2003 | "key": "Content-Type", 2004 | "value": "application/json" 2005 | }, 2006 | { 2007 | "key": "X-Requested-With", 2008 | "value": "XMLHttpRequest" 2009 | } 2010 | ], 2011 | "body": { 2012 | "mode": "raw", 2013 | "raw": "" 2014 | }, 2015 | "url": { 2016 | "raw": "{{APIURL}}/tags", 2017 | "host": ["{{APIURL}}"], 2018 | "path": ["tags"] 2019 | } 2020 | }, 2021 | "response": [] 2022 | } 2023 | ] 2024 | } 2025 | ] 2026 | } 2027 | -------------------------------------------------------------------------------- /api-test/README.md: -------------------------------------------------------------------------------- 1 | # RealWorld API Spec 2 | 3 | ## Running API tests locally 4 | 5 | To locally run the provided Postman collection against your backend, execute: 6 | 7 | ``` 8 | APIURL=http://localhost:3000/api ./run-api-tests.sh 9 | ``` 10 | 11 | For more details, see [`run-api-tests.sh`](run-api-tests.sh). 12 | -------------------------------------------------------------------------------- /api-test/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: RealWorld Conduit API 4 | description: Conduit API documentation 5 | contact: 6 | name: RealWorld 7 | url: https://realworld.how 8 | license: 9 | name: MIT License 10 | url: https://opensource.org/licenses/MIT 11 | version: 1.0.0 12 | tags: 13 | - name: Articles 14 | - name: Comments 15 | - name: Favorites 16 | - name: Profile 17 | - name: Tags 18 | - name: User and Authentication 19 | servers: 20 | - url: https://api.realworld.io/api 21 | paths: 22 | /users/login: 23 | post: 24 | tags: 25 | - User and Authentication 26 | summary: Existing user login 27 | description: Login for existing user 28 | operationId: Login 29 | requestBody: 30 | $ref: '#/components/requestBodies/LoginUserRequest' 31 | responses: 32 | '200': 33 | $ref: '#/components/responses/UserResponse' 34 | '401': 35 | $ref: '#/components/responses/Unauthorized' 36 | '422': 37 | $ref: '#/components/responses/GenericError' 38 | x-codegen-request-body-name: body 39 | /users: 40 | post: 41 | tags: 42 | - User and Authentication 43 | description: Register a new user 44 | operationId: CreateUser 45 | requestBody: 46 | $ref: '#/components/requestBodies/NewUserRequest' 47 | responses: 48 | '201': 49 | $ref: '#/components/responses/UserResponse' 50 | '422': 51 | $ref: '#/components/responses/GenericError' 52 | x-codegen-request-body-name: body 53 | /user: 54 | get: 55 | tags: 56 | - User and Authentication 57 | summary: Get current user 58 | description: Gets the currently logged-in user 59 | operationId: GetCurrentUser 60 | responses: 61 | '200': 62 | $ref: '#/components/responses/UserResponse' 63 | '401': 64 | $ref: '#/components/responses/Unauthorized' 65 | '422': 66 | $ref: '#/components/responses/GenericError' 67 | security: 68 | - Token: [] 69 | put: 70 | tags: 71 | - User and Authentication 72 | summary: Update current user 73 | description: Updated user information for current user 74 | operationId: UpdateCurrentUser 75 | requestBody: 76 | $ref: '#/components/requestBodies/UpdateUserRequest' 77 | responses: 78 | '200': 79 | $ref: '#/components/responses/UserResponse' 80 | '401': 81 | $ref: '#/components/responses/Unauthorized' 82 | '422': 83 | $ref: '#/components/responses/GenericError' 84 | security: 85 | - Token: [] 86 | x-codegen-request-body-name: body 87 | /profiles/{username}: 88 | get: 89 | tags: 90 | - Profile 91 | summary: Get a profile 92 | description: Get a profile of a user of the system. Auth is optional 93 | operationId: GetProfileByUsername 94 | parameters: 95 | - name: username 96 | in: path 97 | description: Username of the profile to get 98 | required: true 99 | schema: 100 | type: string 101 | responses: 102 | '200': 103 | $ref: '#/components/responses/ProfileResponse' 104 | '401': 105 | $ref: '#/components/responses/Unauthorized' 106 | '422': 107 | $ref: '#/components/responses/GenericError' 108 | /profiles/{username}/follow: 109 | post: 110 | tags: 111 | - Profile 112 | summary: Follow a user 113 | description: Follow a user by username 114 | operationId: FollowUserByUsername 115 | parameters: 116 | - name: username 117 | in: path 118 | description: Username of the profile you want to follow 119 | required: true 120 | schema: 121 | type: string 122 | responses: 123 | '200': 124 | $ref: '#/components/responses/ProfileResponse' 125 | '401': 126 | $ref: '#/components/responses/Unauthorized' 127 | '422': 128 | $ref: '#/components/responses/GenericError' 129 | security: 130 | - Token: [] 131 | delete: 132 | tags: 133 | - Profile 134 | summary: Unfollow a user 135 | description: Unfollow a user by username 136 | operationId: UnfollowUserByUsername 137 | parameters: 138 | - name: username 139 | in: path 140 | description: Username of the profile you want to unfollow 141 | required: true 142 | schema: 143 | type: string 144 | responses: 145 | '200': 146 | $ref: '#/components/responses/ProfileResponse' 147 | '401': 148 | $ref: '#/components/responses/Unauthorized' 149 | '422': 150 | $ref: '#/components/responses/GenericError' 151 | security: 152 | - Token: [] 153 | /articles/feed: 154 | get: 155 | tags: 156 | - Articles 157 | summary: Get recent articles from users you follow 158 | description: Get most recent articles from users you follow. Use query parameters 159 | to limit. Auth is required 160 | operationId: GetArticlesFeed 161 | parameters: 162 | - $ref: '#/components/parameters/offsetParam' 163 | - $ref: '#/components/parameters/limitParam' 164 | responses: 165 | '200': 166 | $ref: '#/components/responses/MultipleArticlesResponse' 167 | '401': 168 | $ref: '#/components/responses/Unauthorized' 169 | '422': 170 | $ref: '#/components/responses/GenericError' 171 | security: 172 | - Token: [] 173 | /articles: 174 | get: 175 | tags: 176 | - Articles 177 | summary: Get recent articles globally 178 | description: Get most recent articles globally. Use query parameters to filter 179 | results. Auth is optional 180 | operationId: GetArticles 181 | parameters: 182 | - name: tag 183 | in: query 184 | description: Filter by tag 185 | schema: 186 | type: string 187 | - name: author 188 | in: query 189 | description: Filter by author (username) 190 | schema: 191 | type: string 192 | - name: favorited 193 | in: query 194 | description: Filter by favorites of a user (username) 195 | schema: 196 | type: string 197 | - $ref: '#/components/parameters/offsetParam' 198 | - $ref: '#/components/parameters/limitParam' 199 | responses: 200 | '200': 201 | $ref: '#/components/responses/MultipleArticlesResponse' 202 | '401': 203 | $ref: '#/components/responses/Unauthorized' 204 | '422': 205 | $ref: '#/components/responses/GenericError' 206 | post: 207 | tags: 208 | - Articles 209 | summary: Create an article 210 | description: Create an article. Auth is required 211 | operationId: CreateArticle 212 | requestBody: 213 | $ref: '#/components/requestBodies/NewArticleRequest' 214 | responses: 215 | '201': 216 | $ref: '#/components/responses/SingleArticleResponse' 217 | '401': 218 | $ref: '#/components/responses/Unauthorized' 219 | '422': 220 | $ref: '#/components/responses/GenericError' 221 | security: 222 | - Token: [] 223 | x-codegen-request-body-name: article 224 | /articles/{slug}: 225 | get: 226 | tags: 227 | - Articles 228 | summary: Get an article 229 | description: Get an article. Auth not required 230 | operationId: GetArticle 231 | parameters: 232 | - name: slug 233 | in: path 234 | description: Slug of the article to get 235 | required: true 236 | schema: 237 | type: string 238 | responses: 239 | '200': 240 | $ref: '#/components/responses/SingleArticleResponse' 241 | '422': 242 | $ref: '#/components/responses/GenericError' 243 | put: 244 | tags: 245 | - Articles 246 | summary: Update an article 247 | description: Update an article. Auth is required 248 | operationId: UpdateArticle 249 | parameters: 250 | - name: slug 251 | in: path 252 | description: Slug of the article to update 253 | required: true 254 | schema: 255 | type: string 256 | requestBody: 257 | $ref: '#/components/requestBodies/UpdateArticleRequest' 258 | responses: 259 | '200': 260 | $ref: '#/components/responses/SingleArticleResponse' 261 | '401': 262 | $ref: '#/components/responses/Unauthorized' 263 | '422': 264 | $ref: '#/components/responses/GenericError' 265 | security: 266 | - Token: [] 267 | x-codegen-request-body-name: article 268 | delete: 269 | tags: 270 | - Articles 271 | summary: Delete an article 272 | description: Delete an article. Auth is required 273 | operationId: DeleteArticle 274 | parameters: 275 | - name: slug 276 | in: path 277 | description: Slug of the article to delete 278 | required: true 279 | schema: 280 | type: string 281 | responses: 282 | '200': 283 | $ref: '#/components/responses/EmptyOkResponse' 284 | '401': 285 | $ref: '#/components/responses/Unauthorized' 286 | '422': 287 | $ref: '#/components/responses/GenericError' 288 | security: 289 | - Token: [] 290 | /articles/{slug}/comments: 291 | get: 292 | tags: 293 | - Comments 294 | summary: Get comments for an article 295 | description: Get the comments for an article. Auth is optional 296 | operationId: GetArticleComments 297 | parameters: 298 | - name: slug 299 | in: path 300 | description: Slug of the article that you want to get comments for 301 | required: true 302 | schema: 303 | type: string 304 | responses: 305 | '200': 306 | $ref: '#/components/responses/MultipleCommentsResponse' 307 | '401': 308 | $ref: '#/components/responses/Unauthorized' 309 | '422': 310 | $ref: '#/components/responses/GenericError' 311 | post: 312 | tags: 313 | - Comments 314 | summary: Create a comment for an article 315 | description: Create a comment for an article. Auth is required 316 | operationId: CreateArticleComment 317 | parameters: 318 | - name: slug 319 | in: path 320 | description: Slug of the article that you want to create a comment for 321 | required: true 322 | schema: 323 | type: string 324 | requestBody: 325 | $ref: '#/components/requestBodies/NewCommentRequest' 326 | responses: 327 | '200': 328 | $ref: '#/components/responses/SingleCommentResponse' 329 | '401': 330 | $ref: '#/components/responses/Unauthorized' 331 | '422': 332 | $ref: '#/components/responses/GenericError' 333 | security: 334 | - Token: [] 335 | x-codegen-request-body-name: comment 336 | /articles/{slug}/comments/{id}: 337 | delete: 338 | tags: 339 | - Comments 340 | summary: Delete a comment for an article 341 | description: Delete a comment for an article. Auth is required 342 | operationId: DeleteArticleComment 343 | parameters: 344 | - name: slug 345 | in: path 346 | description: Slug of the article that you want to delete a comment for 347 | required: true 348 | schema: 349 | type: string 350 | - name: id 351 | in: path 352 | description: ID of the comment you want to delete 353 | required: true 354 | schema: 355 | type: integer 356 | responses: 357 | '200': 358 | $ref: '#/components/responses/EmptyOkResponse' 359 | '401': 360 | $ref: '#/components/responses/Unauthorized' 361 | '422': 362 | $ref: '#/components/responses/GenericError' 363 | security: 364 | - Token: [] 365 | /articles/{slug}/favorite: 366 | post: 367 | tags: 368 | - Favorites 369 | summary: Favorite an article 370 | description: Favorite an article. Auth is required 371 | operationId: CreateArticleFavorite 372 | parameters: 373 | - name: slug 374 | in: path 375 | description: Slug of the article that you want to favorite 376 | required: true 377 | schema: 378 | type: string 379 | responses: 380 | '200': 381 | $ref: '#/components/responses/SingleArticleResponse' 382 | '401': 383 | $ref: '#/components/responses/Unauthorized' 384 | '422': 385 | $ref: '#/components/responses/GenericError' 386 | security: 387 | - Token: [] 388 | delete: 389 | tags: 390 | - Favorites 391 | summary: Unfavorite an article 392 | description: Unfavorite an article. Auth is required 393 | operationId: DeleteArticleFavorite 394 | parameters: 395 | - name: slug 396 | in: path 397 | description: Slug of the article that you want to unfavorite 398 | required: true 399 | schema: 400 | type: string 401 | responses: 402 | '200': 403 | $ref: '#/components/responses/SingleArticleResponse' 404 | '401': 405 | $ref: '#/components/responses/Unauthorized' 406 | '422': 407 | $ref: '#/components/responses/GenericError' 408 | security: 409 | - Token: [] 410 | /tags: 411 | get: 412 | tags: 413 | - Tags 414 | summary: Get tags 415 | description: Get tags. Auth not required 416 | operationId: GetTags 417 | responses: 418 | '200': 419 | $ref: '#/components/responses/TagsResponse' 420 | '422': 421 | $ref: '#/components/responses/GenericError' 422 | components: 423 | schemas: 424 | LoginUser: 425 | required: 426 | - email 427 | - password 428 | type: object 429 | properties: 430 | email: 431 | type: string 432 | password: 433 | type: string 434 | format: password 435 | NewUser: 436 | required: 437 | - email 438 | - password 439 | - username 440 | type: object 441 | properties: 442 | username: 443 | type: string 444 | email: 445 | type: string 446 | password: 447 | type: string 448 | format: password 449 | User: 450 | required: 451 | - bio 452 | - email 453 | - image 454 | - token 455 | - username 456 | type: object 457 | properties: 458 | email: 459 | type: string 460 | token: 461 | type: string 462 | username: 463 | type: string 464 | bio: 465 | type: string 466 | image: 467 | type: string 468 | UpdateUser: 469 | type: object 470 | properties: 471 | email: 472 | type: string 473 | password: 474 | type: string 475 | username: 476 | type: string 477 | bio: 478 | type: string 479 | image: 480 | type: string 481 | Profile: 482 | required: 483 | - bio 484 | - following 485 | - image 486 | - username 487 | type: object 488 | properties: 489 | username: 490 | type: string 491 | bio: 492 | type: string 493 | image: 494 | type: string 495 | following: 496 | type: boolean 497 | Article: 498 | required: 499 | - author 500 | - body 501 | - createdAt 502 | - description 503 | - favorited 504 | - favoritesCount 505 | - slug 506 | - tagList 507 | - title 508 | - updatedAt 509 | type: object 510 | properties: 511 | slug: 512 | type: string 513 | title: 514 | type: string 515 | description: 516 | type: string 517 | body: 518 | type: string 519 | tagList: 520 | type: array 521 | items: 522 | type: string 523 | createdAt: 524 | type: string 525 | format: date-time 526 | updatedAt: 527 | type: string 528 | format: date-time 529 | favorited: 530 | type: boolean 531 | favoritesCount: 532 | type: integer 533 | author: 534 | $ref: '#/components/schemas/Profile' 535 | NewArticle: 536 | required: 537 | - body 538 | - description 539 | - title 540 | type: object 541 | properties: 542 | title: 543 | type: string 544 | description: 545 | type: string 546 | body: 547 | type: string 548 | tagList: 549 | type: array 550 | items: 551 | type: string 552 | UpdateArticle: 553 | type: object 554 | properties: 555 | title: 556 | type: string 557 | description: 558 | type: string 559 | body: 560 | type: string 561 | Comment: 562 | required: 563 | - author 564 | - body 565 | - createdAt 566 | - id 567 | - updatedAt 568 | type: object 569 | properties: 570 | id: 571 | type: integer 572 | createdAt: 573 | type: string 574 | format: date-time 575 | updatedAt: 576 | type: string 577 | format: date-time 578 | body: 579 | type: string 580 | author: 581 | $ref: '#/components/schemas/Profile' 582 | NewComment: 583 | required: 584 | - body 585 | type: object 586 | properties: 587 | body: 588 | type: string 589 | GenericErrorModel: 590 | required: 591 | - errors 592 | type: object 593 | properties: 594 | errors: 595 | required: 596 | - body 597 | type: object 598 | properties: 599 | body: 600 | type: array 601 | items: 602 | type: string 603 | responses: 604 | TagsResponse: 605 | description: Tags 606 | content: 607 | application/json: 608 | schema: 609 | required: 610 | - tags 611 | type: object 612 | properties: 613 | tags: 614 | type: array 615 | items: 616 | type: string 617 | SingleCommentResponse: 618 | description: Single comment 619 | content: 620 | application/json: 621 | schema: 622 | required: 623 | - comment 624 | type: object 625 | properties: 626 | comment: 627 | $ref: '#/components/schemas/Comment' 628 | MultipleCommentsResponse: 629 | description: Multiple comments 630 | content: 631 | application/json: 632 | schema: 633 | required: 634 | - comments 635 | type: object 636 | properties: 637 | comments: 638 | type: array 639 | items: 640 | $ref: '#/components/schemas/Comment' 641 | SingleArticleResponse: 642 | description: Single article 643 | content: 644 | application/json: 645 | schema: 646 | required: 647 | - article 648 | type: object 649 | properties: 650 | article: 651 | $ref: '#/components/schemas/Article' 652 | MultipleArticlesResponse: 653 | description: Multiple articles 654 | content: 655 | application/json: 656 | schema: 657 | required: 658 | - articles 659 | - articlesCount 660 | type: object 661 | properties: 662 | articles: 663 | type: array 664 | items: 665 | $ref: '#/components/schemas/Article' 666 | articlesCount: 667 | type: integer 668 | ProfileResponse: 669 | description: Profile 670 | content: 671 | application/json: 672 | schema: 673 | required: 674 | - profile 675 | type: object 676 | properties: 677 | profile: 678 | $ref: '#/components/schemas/Profile' 679 | UserResponse: 680 | description: User 681 | content: 682 | application/json: 683 | schema: 684 | required: 685 | - user 686 | type: object 687 | properties: 688 | user: 689 | $ref: '#/components/schemas/User' 690 | EmptyOkResponse: 691 | description: No content 692 | content: {} 693 | Unauthorized: 694 | description: Unauthorized 695 | content: {} 696 | GenericError: 697 | description: Unexpected error 698 | content: 699 | application/json: 700 | schema: 701 | $ref: '#/components/schemas/GenericErrorModel' 702 | requestBodies: 703 | LoginUserRequest: 704 | required: true 705 | description: Credentials to use 706 | content: 707 | application/json: 708 | schema: 709 | required: 710 | - user 711 | type: object 712 | properties: 713 | user: 714 | $ref: '#/components/schemas/LoginUser' 715 | NewUserRequest: 716 | required: true 717 | description: Details of the new user to register 718 | content: 719 | application/json: 720 | schema: 721 | required: 722 | - user 723 | type: object 724 | properties: 725 | user: 726 | $ref: '#/components/schemas/NewUser' 727 | UpdateUserRequest: 728 | required: true 729 | description: User details to update. At least **one** field is required. 730 | content: 731 | application/json: 732 | schema: 733 | required: 734 | - user 735 | type: object 736 | properties: 737 | user: 738 | $ref: '#/components/schemas/UpdateUser' 739 | NewArticleRequest: 740 | required: true 741 | description: Article to create 742 | content: 743 | application/json: 744 | schema: 745 | required: 746 | - article 747 | type: object 748 | properties: 749 | article: 750 | $ref: '#/components/schemas/NewArticle' 751 | UpdateArticleRequest: 752 | required: true 753 | description: Article to update 754 | content: 755 | application/json: 756 | schema: 757 | required: 758 | - article 759 | type: object 760 | properties: 761 | article: 762 | $ref: '#/components/schemas/UpdateArticle' 763 | NewCommentRequest: 764 | required: true 765 | description: Comment you want to create 766 | content: 767 | application/json: 768 | schema: 769 | required: 770 | - comment 771 | type: object 772 | properties: 773 | comment: 774 | $ref: '#/components/schemas/NewComment' 775 | parameters: 776 | offsetParam: 777 | in: query 778 | name: offset 779 | required: false 780 | schema: 781 | type: integer 782 | minimum: 0 783 | description: The number of items to skip before starting to collect the result set. 784 | limitParam: 785 | in: query 786 | name: limit 787 | required: false 788 | schema: 789 | type: integer 790 | minimum: 1 791 | default: 20 792 | description: The numbers of items to return. 793 | securitySchemes: 794 | Token: 795 | type: apiKey 796 | description: "For accessing the protected API resources, you must have received\ 797 | \ a a valid JWT token after registering or logging in. This JWT token must\ 798 | \ then be used for all protected resources by passing it in via the 'Authorization'\ 799 | \ header.\n\nA JWT token is generated by the API by either registering via\ 800 | \ /users or logging in via /users/login.\n\nThe following format must be in\ 801 | \ the 'Authorization' header :\n\n Token xxxxxx.yyyyyyy.zzzzzz\n \n" 802 | name: Authorization 803 | in: header 804 | -------------------------------------------------------------------------------- /api-test/run-api-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | 6 | APIURL=${APIURL:-https://api.realworld.io/api} 7 | USERNAME=${USERNAME:-u`date +%s`} 8 | EMAIL=${EMAIL:-$USERNAME@mail.com} 9 | PASSWORD=${PASSWORD:-password} 10 | 11 | pnpm newman run $SCRIPTDIR/Conduit.postman_collection.json \ 12 | --delay-request 500 \ 13 | --global-var "APIURL=$APIURL" \ 14 | --global-var "USERNAME=$USERNAME" \ 15 | --global-var "EMAIL=$EMAIL" \ 16 | --global-var "PASSWORD=$PASSWORD" \ 17 | "$@" 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | environment: 7 | POSTGRES_USER: ${TYPEORM_USERNAME:-"realworld"} 8 | POSTGRES_PASSWORD: ${TYPEORM_PASSWORD:-"123456"} 9 | POSTGRES_DB: ${TYPEORM_DATABASE:-"nestjs_realworld"} 10 | ports: 11 | - "5432:5432" 12 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-realworld-example-app", 3 | "version": "0.2", 4 | "main": "index.js", 5 | "repository": "https://github.com/mutoe/nestjs-realworld-example-app", 6 | "author": "mutoe ", 7 | "license": "MIT", 8 | "private": true, 9 | "scripts": { 10 | "prebuild": "rimraf dist", 11 | "build": "nest build", 12 | "build:docker": "npm run build && docker build -t mutoe/$npm_package_name:latest . && docker push mutoe/$npm_package_name:latest", 13 | "start": "nest start", 14 | "start:dev": "nest start --watch", 15 | "start:debug": "nest start --debug --watch", 16 | "start:prod": "node dist/main", 17 | "lint": "eslint . --fix", 18 | "test": "jest --coverage", 19 | "test:watch": "jest --watch", 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 --runInBand", 22 | "test:api": "bash api-test/run-api-test.sh" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^9.4.0", 26 | "@nestjs/core": "^9.4.0", 27 | "@nestjs/jwt": "^10.0.3", 28 | "@nestjs/passport": "^9.0.3", 29 | "@nestjs/platform-express": "^9.4.0", 30 | "@nestjs/swagger": "^6.3.0", 31 | "@nestjs/typeorm": "^9.0.1", 32 | "class-transformer": "^0.5.1", 33 | "class-validator": "^0.14.0", 34 | "lodash": "^4.17.21", 35 | "passport": "^0.6.0", 36 | "passport-jwt": "^4.0.1", 37 | "passport-local": "^1.0.0", 38 | "pg": "^8.10.0", 39 | "reflect-metadata": "^0.1.13", 40 | "rimraf": "^5.0.0", 41 | "rxjs": "^7.8.1", 42 | "swagger-ui-express": "^4.6.2", 43 | "typeorm": "^0.2" 44 | }, 45 | "devDependencies": { 46 | "@mutoe/eslint-config-preset-jest": "^3.4.1", 47 | "@mutoe/eslint-config-preset-ts": "^3.4.1", 48 | "@nestjs/cli": "^9.4.2", 49 | "@nestjs/schematics": "^9.1.0", 50 | "@nestjs/testing": "^9.4.0", 51 | "@types/jest": "^27.4.1", 52 | "@types/lodash": "^4.14.194", 53 | "@types/node": "^18", 54 | "@types/passport-jwt": "^3.0.8", 55 | "@types/passport-local": "^1.0.35", 56 | "@types/supertest": "^2.0.12", 57 | "eslint": "^8.39.0", 58 | "jest": "^27.5.1", 59 | "newman": "^5.3.2", 60 | "supertest": "^6.3.3", 61 | "ts-jest": "^27.1.5", 62 | "ts-loader": "^9.4.2", 63 | "ts-node": "^10.9.1", 64 | "tsconfig-paths": "^4.2.0", 65 | "typescript": "^4.9.5" 66 | }, 67 | "eslintConfig": { 68 | "root": true, 69 | "extends": [ 70 | "@mutoe/eslint-config-preset-ts" 71 | ] 72 | }, 73 | "jest": { 74 | "moduleFileExtensions": [ 75 | "js", 76 | "json", 77 | "ts" 78 | ], 79 | "rootDir": "src", 80 | "modulePaths": [ 81 | "/" 82 | ], 83 | "transform": { 84 | "^.+\\.(t|j)s$": "ts-jest" 85 | }, 86 | "coverageDirectory": "/../coverage", 87 | "testEnvironment": "node" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /project-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mutoe/nestjs-realworld-example-app/0c30a1828af52cf6528d0b3e9ddf86fc459723f6/project-logo.png -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtService } from '@nestjs/jwt' 2 | import { Test, TestingModule } from '@nestjs/testing' 3 | import { getRepositoryToken } from '@nestjs/typeorm' 4 | import { AppController } from 'app.controller' 5 | import { AuthService } from 'auth/auth.service' 6 | import { Repository } from 'typeorm' 7 | import { UserEntity } from 'user/user.entity' 8 | import { UserService } from 'user/user.service' 9 | 10 | describe('AppController', () => { 11 | let appController: AppController 12 | 13 | beforeEach(async () => { 14 | const app: TestingModule = await Test.createTestingModule({ 15 | controllers: [AppController], 16 | providers: [ 17 | UserService, 18 | AuthService, 19 | { 20 | provide: getRepositoryToken(UserEntity), 21 | useClass: Repository, 22 | }, 23 | { 24 | provide: JwtService, 25 | useValue: {}, 26 | }, 27 | ], 28 | }).compile() 29 | 30 | appController = app.get(AppController) 31 | }) 32 | 33 | describe('Hello', () => { 34 | it('should return "Hello World!"', () => { 35 | expect(appController.healthCheck()).toBe('Hello world!') 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common' 2 | import { ApiQuery } from '@nestjs/swagger' 3 | 4 | @Controller() 5 | export class AppController { 6 | @Get('/hello') 7 | @ApiQuery({ name: 'name', required: false }) 8 | healthCheck (@Query('name') name?: string): string { 9 | return `Hello ${name || 'world'}!` 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | import { AppController } from 'app.controller' 4 | import { AuthModule } from 'auth/auth.module' 5 | import { UserModule } from 'user/user.module' 6 | 7 | @Module({ 8 | imports: [ 9 | TypeOrmModule.forRoot(), 10 | UserModule, 11 | AuthModule, 12 | ], 13 | exports: [TypeOrmModule], 14 | controllers: [AppController], 15 | }) 16 | export class AppModule {} 17 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtService } from '@nestjs/jwt' 2 | import { Test, TestingModule } from '@nestjs/testing' 3 | import { getRepositoryToken } from '@nestjs/typeorm' 4 | import { AuthService } from 'auth/auth.service' 5 | import { RegisterDto } from 'auth/dto/register.dto' 6 | import { UserEntity } from 'user/user.entity' 7 | import { UserService } from 'user/user.service' 8 | import { AuthController } from './auth.controller' 9 | 10 | describe('Auth Controller', () => { 11 | let controller: AuthController 12 | let authService: AuthService 13 | 14 | const mockUserProfile = { 15 | id: 1, 16 | email: 'foo@bar.com', 17 | createdAt: '', 18 | updatedAt: '', 19 | username: 'foo', 20 | bio: null, 21 | image: null, 22 | token: 'token', 23 | } 24 | 25 | beforeEach(async () => { 26 | const module: TestingModule = await Test.createTestingModule({ 27 | controllers: [AuthController], 28 | providers: [ 29 | AuthService, 30 | UserService, 31 | { 32 | provide: getRepositoryToken(UserEntity), 33 | useValue: {}, 34 | }, 35 | { 36 | provide: JwtService, 37 | useValue: {}, 38 | }, 39 | ], 40 | }).compile() 41 | 42 | controller = module.get(AuthController) 43 | authService = module.get(AuthService) 44 | }) 45 | 46 | it('should be defined', () => { 47 | expect(controller).toBeDefined() 48 | }) 49 | 50 | describe('register', () => { 51 | it('should call register service when call register controller', async () => { 52 | jest.spyOn(authService, 'register').mockResolvedValue(mockUserProfile) 53 | 54 | const registerDto: RegisterDto = { email: 'foo@bar.com', username: 'foo', password: 'bar' } 55 | const res = await controller.register(registerDto) 56 | 57 | expect(res).toHaveProperty('user') 58 | expect(res.user).not.toHaveProperty('password') 59 | expect(res.user).toEqual(mockUserProfile) 60 | }) 61 | }) 62 | 63 | describe('login', () => { 64 | it('should call login service when call login controller', async () => { 65 | jest.spyOn(authService, 'login').mockResolvedValue(mockUserProfile) 66 | 67 | const res = await controller.login({ email: 'foo@bar.com', password: '123456' }) 68 | 69 | expect(res).toHaveProperty('user') 70 | expect(res.user).not.toHaveProperty('password') 71 | expect(res.user).toEqual(mockUserProfile) 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common' 2 | import { AuthRO } from 'auth/auth.interface' 3 | import { LoginDto } from 'auth/dto/login.dto' 4 | import { RegisterDto } from 'auth/dto/register.dto' 5 | import { UserService } from 'user/user.service' 6 | import { AuthService } from './auth.service' 7 | 8 | @Controller('auth') 9 | export class AuthController { 10 | constructor ( 11 | private readonly userService: UserService, 12 | private readonly authService: AuthService, 13 | ) {} 14 | 15 | @Post('/register') 16 | async register (@Body() registerDto: RegisterDto): Promise { 17 | const userProfile = await this.authService.register(registerDto) 18 | return { 19 | user: userProfile, 20 | } 21 | } 22 | 23 | @Post('/login') 24 | @HttpCode(HttpStatus.OK) 25 | async login (@Body() loginDto: LoginDto): Promise { 26 | const userProfile = await this.authService.login(loginDto) 27 | return { 28 | user: userProfile, 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/auth/auth.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AuthData { 2 | id: number 3 | username: string 4 | createdAt: string 5 | updatedAt: string 6 | email: string 7 | bio: string | null 8 | image: string | null 9 | token: string 10 | // password?: never; 11 | } 12 | 13 | export interface AuthRO { 14 | user: AuthData 15 | } 16 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { JwtModule } from '@nestjs/jwt' 3 | import { PassportModule } from '@nestjs/passport' 4 | import { TypeOrmModule } from '@nestjs/typeorm' 5 | import { NEST_SECRET } from 'config' 6 | import { UserEntity } from 'user/user.entity' 7 | import { UserModule } from 'user/user.module' 8 | import { AuthController } from './auth.controller' 9 | import { AuthService } from './auth.service' 10 | import { JwtStrategy } from './jwt.strategy' 11 | import { LocalStrategy } from './local.strategy' 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([UserEntity]), 16 | UserModule, 17 | PassportModule, 18 | JwtModule.register({ 19 | secret: NEST_SECRET, 20 | }), 21 | ], 22 | providers: [AuthService, LocalStrategy, JwtStrategy], 23 | exports: [AuthService], 24 | controllers: [AuthController], 25 | }) 26 | export class AuthModule {} 27 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | import { Test, TestingModule } from '@nestjs/testing' 4 | import { getRepositoryToken } from '@nestjs/typeorm' 5 | import { LoginDto } from 'auth/dto/login.dto' 6 | import { UserEntity } from 'user/user.entity' 7 | import { UserService } from 'user/user.service' 8 | import { cryptoPassword } from 'utils' 9 | import { AuthService } from './auth.service' 10 | 11 | describe('AuthService', () => { 12 | let authService: AuthService 13 | let userService: UserService 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | providers: [ 18 | UserService, 19 | AuthService, 20 | { 21 | provide: getRepositoryToken(UserEntity), 22 | useValue: {}, 23 | }, 24 | { 25 | provide: JwtService, 26 | useValue: { 27 | sign: jest.fn().mockReturnValue('token'), 28 | }, 29 | }, 30 | ], 31 | }).compile() 32 | 33 | authService = module.get(AuthService) 34 | userService = module.get(UserService) 35 | }) 36 | 37 | it('should be defined', () => { 38 | expect(authService).toBeDefined() 39 | }) 40 | 41 | describe('register', () => { 42 | it('should return user profile when register successful', async () => { 43 | jest.spyOn(userService, 'findUser').mockResolvedValue(undefined) 44 | jest.spyOn(userService, 'createUser').mockResolvedValue({ foo: 'bar' } as never) 45 | jest.spyOn(authService, 'generateToken').mockReturnValue('token') 46 | const registerDto = { email: 'foo@bar.com', username: 'foobar', password: '123456' } 47 | 48 | const authData = await authService.register(registerDto) 49 | 50 | expect(authData).not.toHaveProperty('password') 51 | expect(authData).toHaveProperty('foo', 'bar') 52 | expect(authData).toHaveProperty('token', 'token') 53 | }) 54 | 55 | it('should throw error when user name is exist', async () => { 56 | jest.spyOn(userService, 'findUser').mockResolvedValue({ id: 1, username: 'exist_user' } as UserEntity) 57 | const registerDto = { email: 'foo@bar.com', username: 'exist_user', password: '123456' } 58 | 59 | await expect(authService.register(registerDto)).rejects.toThrow(new BadRequestException('username is exist')) 60 | }) 61 | 62 | it('should throw error when email is exist', async () => { 63 | jest.spyOn(userService, 'findUser').mockResolvedValueOnce(undefined) 64 | jest.spyOn(userService, 'findUser').mockResolvedValueOnce({ id: 1, email: 'exist_email@bar.com' } as UserEntity) 65 | const registerDto = { email: 'exist_email@bar.com', username: 'username', password: '123456' } 66 | 67 | await expect(authService.register(registerDto)).rejects.toThrow(new BadRequestException('email is exist')) 68 | }) 69 | }) 70 | 71 | describe('login', () => { 72 | it('should return user profile when login successful', async () => { 73 | jest.spyOn(authService, 'validateUser').mockResolvedValue({ foo: 'bar' } as never) 74 | jest.spyOn(authService, 'generateToken').mockReturnValue('token') 75 | const loginDto: LoginDto = { email: 'foo@bar.com', password: '123456' } 76 | 77 | const authData = await authService.login(loginDto) 78 | 79 | expect(authData).not.toHaveProperty('password') 80 | expect(authData).toHaveProperty('foo', 'bar') 81 | expect(authData).toHaveProperty('token', 'token') 82 | }) 83 | }) 84 | 85 | describe('validate user', () => { 86 | it('should return user info without password when validate successful', async () => { 87 | const email = 'foo@bar.com' 88 | const password = cryptoPassword('12345678') 89 | jest.spyOn(userService, 'findUser').mockResolvedValue({ email, password } as UserEntity) 90 | const user = await authService.validateUser(email, '12345678') 91 | 92 | expect(user).toHaveProperty('email', email) 93 | expect(user).not.toHaveProperty('password') 94 | }) 95 | 96 | it('should throw bad request exception when invalid user', async () => { 97 | jest.spyOn(userService, 'findUser').mockResolvedValue(undefined) 98 | 99 | const validateUser = authService.validateUser('foo@bar.com', '') 100 | await expect(validateUser).rejects.toThrow(new BadRequestException('user is not exist')) 101 | }) 102 | 103 | it('should throw bad request exception when invalid password', async () => { 104 | const password = '4a83854cf6f0112b4295bddd535a9b3fbe54a3f90e853b59d42e4bed553c55a4' 105 | jest.spyOn(userService, 'findUser').mockResolvedValue({ email: 'foo@bar.com', password } as UserEntity) 106 | 107 | const validateUser = authService.validateUser('foo@bar.com', 'invalidPassword') 108 | await expect(validateUser).rejects.toThrow(new BadRequestException('password is invalid')) 109 | }) 110 | }) 111 | 112 | describe('generateToken', () => { 113 | it('should return JWT', () => { 114 | const token = authService.generateToken(1, 'foo@bar.com') 115 | 116 | expect(token).toBe('token') 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | import { AuthData } from 'auth/auth.interface' 4 | import { LoginDto } from 'auth/dto/login.dto' 5 | import { RegisterDto } from 'auth/dto/register.dto' 6 | import { omit } from 'lodash' 7 | import { UserEntity } from 'user/user.entity' 8 | import { UserService } from 'user/user.service' 9 | import { cryptoPassword } from 'utils' 10 | 11 | @Injectable() 12 | export class AuthService { 13 | constructor ( 14 | private readonly userService: UserService, 15 | private readonly jwtService: JwtService, 16 | ) {} 17 | 18 | async register (registerDto: RegisterDto): Promise { 19 | let user: UserEntity 20 | user = await this.userService.findUser({ username: registerDto.username }) 21 | if (user?.id) { 22 | throw new BadRequestException('username is exist') 23 | } 24 | user = await this.userService.findUser({ email: registerDto.email }) 25 | if (user?.id) { 26 | throw new BadRequestException('email is exist') 27 | } 28 | const profile = await this.userService.createUser(registerDto) 29 | const token = this.generateToken(profile.id, profile.email) 30 | return { ...profile, token } 31 | } 32 | 33 | async login (loginDto: LoginDto): Promise { 34 | const user = await this.validateUser(loginDto.email, loginDto.password) 35 | const token = this.generateToken(user.id, user.email) 36 | return { ...user, token } 37 | } 38 | 39 | async validateUser (email: string, password: string): Promise> { 40 | const user = await this.userService.findUser({ email }, true) 41 | if (!user) { 42 | throw new BadRequestException('user is not exist') 43 | } 44 | if (user.password !== cryptoPassword(password)) { 45 | throw new BadRequestException('password is invalid') 46 | } 47 | return omit(user, 'password') 48 | } 49 | 50 | generateToken (userId: number, email: string): string { 51 | return this.jwtService.sign({ userId, email }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsEmail, IsNotEmpty } from 'class-validator' 3 | 4 | export class LoginDto { 5 | @IsEmail() 6 | @ApiProperty({ example: 'foo@example.com' }) 7 | readonly email: string 8 | 9 | @IsNotEmpty() 10 | @ApiProperty({ example: '123456' }) 11 | readonly password: string 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsEmail, IsNotEmpty } from 'class-validator' 3 | 4 | export class RegisterDto { 5 | @IsEmail() 6 | @ApiProperty({ example: 'foo@example.com' }) 7 | readonly email: string 8 | 9 | @IsNotEmpty() 10 | @ApiProperty({ example: 'username' }) 11 | readonly username: string 12 | 13 | @IsNotEmpty() 14 | @ApiProperty({ example: '123456' }) 15 | readonly password: string 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { NEST_SECRET } from 'config' 4 | import { ExtractJwt, Strategy } from 'passport-jwt' 5 | 6 | @Injectable() 7 | export class JwtStrategy extends PassportStrategy(Strategy) { 8 | constructor () { 9 | super({ 10 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 11 | ignoreExpiration: false, 12 | secretOrKey: NEST_SECRET, 13 | }) 14 | } 15 | 16 | validate (payload: { userId: number, email: string }): {userId: number, email: string} { 17 | const { userId, email } = payload 18 | return { userId, email } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { Strategy } from 'passport-local' 4 | import { UserEntity } from 'user/user.entity' 5 | import { AuthService } from './auth.service' 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | constructor (private readonly authService: AuthService) { 10 | super() 11 | } 12 | 13 | async validate (email: string, password: string): Promise> { 14 | const user = await this.authService.validateUser(email, password) 15 | if (!user) { 16 | throw new UnauthorizedException() 17 | } 18 | return user 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const NEST_SECRET = process.env.NEST_SECRET ?? 'secret' 2 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, ValidationPipe } from '@nestjs/common' 2 | import { NestFactory } from '@nestjs/core' 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' 4 | import { AppModule } from 'app.module' 5 | import { version } from '../package.json' 6 | 7 | function createSwagger (app: INestApplication) { 8 | const options = new DocumentBuilder() 9 | .setTitle('Nestjs Realworld Example App') 10 | .setVersion(version) 11 | .addBearerAuth() 12 | .build() 13 | 14 | const document = SwaggerModule.createDocument(app, options) 15 | SwaggerModule.setup('/docs', app, document) 16 | } 17 | 18 | async function bootstrap () { 19 | const app = await NestFactory.create(AppModule) 20 | app.useGlobalPipes(new ValidationPipe()) 21 | 22 | if (process.env.SWAGGER_ENABLE && process.env.SWAGGER_ENABLE === 'true') { 23 | createSwagger(app) 24 | } 25 | 26 | await app.listen(3000) 27 | } 28 | 29 | bootstrap().catch(error => console.error(error)) 30 | -------------------------------------------------------------------------------- /src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { UserController } from './user.controller' 3 | 4 | describe('User Controller', () => { 5 | let controller: UserController 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserController], 10 | providers: [ 11 | ], 12 | }).compile() 13 | 14 | controller = module.get(UserController) 15 | }) 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined() 19 | }) 20 | 21 | describe('profile', () => { 22 | it('should get user info given a jwt token', async () => { 23 | const user = await controller.profile({}) 24 | 25 | expect(user).toBe(undefined) 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Request, UseGuards } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | 4 | @Controller('user') 5 | export class UserController { 6 | @UseGuards(AuthGuard('jwt')) 7 | @Get('/') 8 | async profile (@Request() req) { 9 | return req.user 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BeforeInsert, 3 | BeforeUpdate, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm' 10 | import { cryptoPassword } from 'utils' 11 | 12 | const nullable = true 13 | 14 | @Entity('user') 15 | export class UserEntity { 16 | @PrimaryGeneratedColumn() 17 | id: number 18 | 19 | @Column({ length: 80 }) 20 | email: string 21 | 22 | @Column({ length: 20 }) 23 | username: string 24 | 25 | @Column({ length: 64, select: false }) 26 | password: string 27 | 28 | @BeforeUpdate() 29 | @BeforeInsert() 30 | hashPassword () { 31 | this.password = cryptoPassword(this.password) 32 | } 33 | 34 | @Column({ nullable, type: 'text' }) 35 | bio: null | string 36 | 37 | @Column({ nullable, type: 'text' }) 38 | image: null | string 39 | 40 | @CreateDateColumn() 41 | createdAt: string 42 | 43 | @UpdateDateColumn() 44 | updatedAt: string 45 | } 46 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | import { UserController } from './user.controller' 4 | import { UserEntity } from './user.entity' 5 | import { UserService } from './user.service' 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([UserEntity])], 9 | providers: [UserService], 10 | exports: [UserService], 11 | controllers: [UserController], 12 | }) 13 | export class UserModule {} 14 | -------------------------------------------------------------------------------- /src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { getRepositoryToken } from '@nestjs/typeorm' 3 | import { Repository } from 'typeorm' 4 | import { UserEntity } from './user.entity' 5 | import { UserService } from './user.service' 6 | 7 | describe('UserService', () => { 8 | let service: UserService 9 | let repository: Repository 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | UserService, 15 | { 16 | provide: getRepositoryToken(UserEntity), 17 | useValue: { 18 | save: jest.fn(), 19 | findOne: jest.fn(), 20 | metadata: { 21 | propertiesMap: {}, 22 | }, 23 | }, 24 | }, 25 | ], 26 | }).compile() 27 | 28 | service = module.get(UserService) 29 | repository = module.get(getRepositoryToken(UserEntity)) 30 | }) 31 | 32 | it('should be defined', () => { 33 | expect(service).toBeDefined() 34 | expect(repository).toBeDefined() 35 | }) 36 | 37 | describe('create user', () => { 38 | it('should create user correctly', async () => { 39 | const user = { email: 'mutoe@foxmail.com', username: 'mutoe', password: '12345678' } 40 | await service.createUser(user) 41 | 42 | expect(repository.save).toBeCalledWith(Object.assign(new UserEntity(), user)) 43 | }) 44 | }) 45 | 46 | describe('find user', () => { 47 | it('should find user correctly', async () => { 48 | const user = { email: 'mutoe@foxmail.com', username: 'mutoe' } 49 | jest.spyOn(repository, 'findOne').mockResolvedValue(user as UserEntity) 50 | const userResult = await service.findUser({ username: user.username }) 51 | 52 | expect(userResult).toBe(user) 53 | expect(userResult).not.toHaveProperty('password') 54 | expect(repository.findOne).toBeCalledWith({ where: { username: user.username } }) 55 | }) 56 | 57 | it('should find user without password when pass withoutPassword true', async () => { 58 | const user = { email: 'mutoe@foxmail.com', username: 'mutoe', password: '12345678' } 59 | jest.spyOn(repository, 'findOne').mockResolvedValue(user as UserEntity) 60 | repository.metadata.propertiesMap = { username: 'username', password: 'password' } 61 | const userResult = await service.findUser({ username: user.username }, true) 62 | 63 | expect(userResult).toHaveProperty('password', '12345678') 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { InjectRepository } from '@nestjs/typeorm' 3 | import { omit } from 'lodash' 4 | import { Repository } from 'typeorm' 5 | import { UserEntity } from './user.entity' 6 | 7 | @Injectable() 8 | export class UserService { 9 | constructor ( 10 | @InjectRepository(UserEntity) 11 | private readonly userRepository: Repository, 12 | ) {} 13 | 14 | async createUser (userInfo: { email: string, username: string, password: string }) { 15 | const result = await this.userRepository.save(Object.assign(new UserEntity(), userInfo)) 16 | return omit(result, ['password']) 17 | } 18 | 19 | async findUser (by: { username?: string, email?: string }, withPassword = false) { 20 | if (!withPassword) return this.userRepository.findOne({ where: by }) 21 | 22 | const select = Object.keys(this.userRepository.metadata.propertiesMap) as (keyof UserEntity)[] 23 | return this.userRepository.findOne({ where: by, select }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { cryptoPassword } from 'utils' 2 | 3 | describe('Utilities', () => { 4 | it('cryptoPassword', () => { 5 | const hashedPassword = cryptoPassword('foobar') 6 | 7 | expect(hashedPassword).toBe('4fcc06915b43d8a49aff193441e9e18654e6a27c2c428b02e8fcc41ccc2299f9') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { createHmac } from 'node:crypto' 2 | import { NEST_SECRET } from 'config' 3 | 4 | export function cryptoPassword (password: string): string { 5 | const hmac = createHmac('sha256', NEST_SECRET) 6 | return hmac.update(password).digest('hex') 7 | } 8 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "jest/expect-expect": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { AppController } from 'app.controller' 3 | import * as request from 'supertest' 4 | 5 | describe('App Module Integration', () => { 6 | let app 7 | 8 | beforeAll(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | }).compile() 12 | 13 | app = moduleFixture.createNestApplication() 14 | await app.init() 15 | }) 16 | 17 | afterAll(async () => { 18 | await app.close() 19 | }) 20 | 21 | it('/hello (GET)', () => { 22 | return request(app.getHttpServer()) 23 | .get('/hello?name=world') 24 | .expect(200) 25 | .expect('Hello world!') 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | import { AppController } from 'app.controller' 4 | import { AuthModule } from 'auth/auth.module' 5 | import * as request from 'supertest' 6 | import { UserModule } from 'user/user.module' 7 | import ormConfig from './orm-config' 8 | 9 | describe('Auth Module Integration', () => { 10 | let app 11 | 12 | beforeAll(async () => { 13 | const moduleFixture: TestingModule = await Test.createTestingModule({ 14 | imports: [ 15 | TypeOrmModule.forRoot(ormConfig), 16 | UserModule, 17 | AuthModule, 18 | ], 19 | controllers: [AppController], 20 | }).compile() 21 | 22 | app = moduleFixture.createNestApplication() 23 | await app.init() 24 | }) 25 | 26 | afterAll(async () => { 27 | await app.close() 28 | }) 29 | 30 | describe('/auth/register (POST)', () => { 31 | it('should return 201', async () => { 32 | const requestBody = { 33 | username: 'mutoe', 34 | email: 'mutoe@foxmail.com', 35 | password: '12345678', 36 | } 37 | const response = await request(app.getHttpServer()) 38 | .post('/auth/register') 39 | .send(requestBody) 40 | 41 | expect(response.status).toBe(201) 42 | }) 43 | 44 | it('should return 400 given exist username', async () => { 45 | const requestBody = { 46 | username: 'mutoe', 47 | email: 'foo@bar.com', 48 | password: '12345678', 49 | } 50 | const response = await request(app.getHttpServer()) 51 | .post('/auth/register') 52 | .send(requestBody) 53 | 54 | expect(response.status).toBe(400) 55 | expect(response.body).toHaveProperty('message', 'username is exist') 56 | }) 57 | 58 | it('should return 400 given exist email', async () => { 59 | const requestBody = { 60 | username: 'foobar', 61 | email: 'mutoe@foxmail.com', 62 | password: '12345678', 63 | } 64 | const response = await request(app.getHttpServer()) 65 | .post('/auth/register') 66 | .send(requestBody) 67 | 68 | expect(response.status).toBe(400) 69 | expect(response.body).toHaveProperty('message', 'email is exist') 70 | }) 71 | }) 72 | 73 | describe('/auth/login (POST)', () => { 74 | it('should return 200 when login given correct user name and password', async () => { 75 | const requestBody = { 76 | email: 'mutoe@foxmail.com', 77 | password: '12345678', 78 | } 79 | const response = await request(app.getHttpServer()) 80 | .post('/auth/login') 81 | .send(requestBody) 82 | 83 | expect(response.status).toBe(200) 84 | }) 85 | 86 | it('should return 400 when login given incorrect user name', async () => { 87 | const requestBody = { 88 | email: 'not-exist@example.com', 89 | password: '12345678', 90 | } 91 | const response = await request(app.getHttpServer()) 92 | .post('/auth/login') 93 | .send(requestBody) 94 | 95 | expect(response.status).toBe(400) 96 | expect(response.body).toHaveProperty('message', 'user is not exist') 97 | }) 98 | 99 | it('should return 400 when login given incorrect password', async () => { 100 | const requestBody = { 101 | email: 'mutoe@foxmail.com', 102 | password: 'invalid', 103 | } 104 | const response = await request(app.getHttpServer()) 105 | .post('/auth/login') 106 | .send(requestBody) 107 | 108 | expect(response.status).toBe(400) 109 | expect(response.body).toHaveProperty('message', 'password is invalid') 110 | }) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "js", 4 | "json", 5 | "ts" 6 | ], 7 | "rootDir": ".", 8 | "modulePaths": [ 9 | "/../src/" 10 | ], 11 | "testEnvironment": "node", 12 | "testRegex": ".e2e-spec.ts$", 13 | "transform": { 14 | "^.+\\.ts$": "ts-jest" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/orm-config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm' 2 | import { UserEntity } from 'user/user.entity' 3 | 4 | const ormConfig: TypeOrmModuleOptions = { 5 | type: 'postgres', 6 | host: 'localhost', 7 | port: 5432, 8 | username: 'realworld', 9 | password: '123456', 10 | database: 'nestjs_test', 11 | entities: [UserEntity], 12 | dropSchema: true, 13 | synchronize: true, 14 | } 15 | 16 | export default ormConfig 17 | -------------------------------------------------------------------------------- /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 | "resolveJsonModule": true, 9 | "target": "ES6", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./src", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | }, 16 | "include": [ 17 | "src", 18 | "test", 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "dist", 23 | ], 24 | } 25 | --------------------------------------------------------------------------------