├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE.md ├── README.md ├── compose.yml ├── configs ├── .env ├── docker.env └── tests.env ├── infrastructure ├── dashboard.json ├── prometheus.yml └── promtail.yml ├── package.json ├── src ├── app.js ├── config │ ├── config.js │ └── logger.js ├── controllers │ ├── health.js │ └── task.js ├── db │ └── index.js ├── docs │ ├── definition.yml │ └── swagger.js ├── index.js ├── middlewares │ ├── catchAsync.js │ ├── error.js │ ├── logger.js │ └── validate.js ├── models │ └── task.js ├── routes │ ├── docs │ │ └── index.js │ ├── health │ │ └── index.js │ └── v1 │ │ ├── index.js │ │ └── tasks │ │ └── index.js ├── services │ ├── health.js │ └── task.js ├── utils │ └── pick.js └── validation │ └── task.js ├── tests ├── integration │ ├── server.js │ └── task.test.js └── unit │ ├── services │ └── task.test.js │ └── utils │ └── pick.test.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | .github 5 | .vscode 6 | coverage 7 | logs -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | infrastructure 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": "airbnb-base", 8 | "overrides": [ 9 | { 10 | "files": [ 11 | "tests/**/*.js" 12 | ], 13 | "env": { 14 | "jest": true 15 | } 16 | } 17 | ], 18 | "parserOptions": { 19 | "ecmaVersion": "latest" 20 | }, 21 | "rules": {} 22 | } -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: App CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 20 17 | cache: "yarn" 18 | - run: yarn install --frozen-lockfile 19 | - run: yarn run lint 20 | - run: docker-compose up -d mongo 21 | - run: yarn test -- --verbose --coverage 22 | - run: docker-compose build 23 | - run: docker-compose logs 24 | if: always() 25 | - run: docker-compose down --volumes 26 | if: always() 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # yarn error logs 5 | yarn-error.log 6 | 7 | # Code coverage 8 | coverage 9 | 10 | # Logs 11 | logs -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "rvest.vs-code-prettier-eslint" 7 | ], 8 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 9 | "unwantedRecommendations": [] 10 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}\\src\\index.js" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Jest: current file", 20 | "program": "${workspaceFolder}/node_modules/.bin/jest", 21 | "windows": { 22 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 23 | }, 24 | "args": [ 25 | "${fileBasenameNoExtension}", 26 | ], 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "rvest.vs-code-prettier-eslint", 4 | }, 5 | "editor.formatOnPaste": false, // required 6 | "editor.formatOnType": false, // required 7 | "editor.formatOnSave": true, // optional 8 | "editor.formatOnSaveMode": "file", // required to format on save 9 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install --frozen-lockfile 7 | 8 | COPY src /app/src 9 | 10 | CMD ["node", "./src/index.js"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dmytro Misik 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Task Management 2 | 3 | Simple application for managing tasks written in Node.js. 4 | 5 | ## How to start 6 | 7 | ### Prerequisites 8 | 9 | - docker compose 10 | 11 | ### Starting the application 12 | 13 | 1. Clone the repository 14 | 2. Run `docker-compose up -d` in the root directory 15 | 16 | ## API 17 | 18 | API documentation is available at [http://localhost:8081/docs](http://localhost:8081/docs) 19 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | app: 5 | build: . 6 | ports: 7 | - '8081:80' 8 | depends_on: 9 | - mongo 10 | volumes: 11 | - ./configs/docker.env:/app/configs/.env 12 | - logs:/app/logs:rw 13 | 14 | mongo: 15 | image: mongo:5 16 | restart: always 17 | ports: 18 | - 27017:27017 19 | volumes: 20 | - mongodata:/data/db 21 | healthcheck: 22 | test: echo 'db.runCommand("ping").ok' | mongo localhost:27017/test --quiet 23 | interval: 10s 24 | timeout: 2s 25 | retries: 5 26 | start_period: 5s 27 | 28 | loki: 29 | image: grafana/loki:2.9.0 30 | expose: 31 | - 3100 32 | command: -config.file=/etc/loki/local-config.yaml 33 | 34 | promtail: 35 | image: grafana/promtail:2.9.0 36 | volumes: 37 | - logs:/var/log:rw 38 | - ./infrastructure/promtail.yml:/etc/promtail/config.yml 39 | command: -config.file=/etc/promtail/config.yml 40 | 41 | prometheus: 42 | image: prom/prometheus:latest 43 | volumes: 44 | - ./infrastructure/prometheus.yml:/etc/prometheus/prometheus.yml 45 | command: 46 | - '--config.file=/etc/prometheus/prometheus.yml' 47 | expose: 48 | - 9090 49 | 50 | grafana: 51 | image: grafana/grafana:latest 52 | volumes: 53 | - grafanadata:/var/lib/grafana 54 | environment: 55 | - GF_PATHS_PROVISIONING=/etc/grafana/provisioning 56 | - GF_AUTH_ANONYMOUS_ENABLED=true 57 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 58 | ports: 59 | - 3000:3000 60 | 61 | volumes: 62 | mongodata: 63 | grafanadata: 64 | logs: 65 | -------------------------------------------------------------------------------- /configs/.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3001 3 | MONGODB_URL=mongodb://localhost:27017/task-management-local 4 | LOG_LEVEL=debug -------------------------------------------------------------------------------- /configs/docker.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | PORT=80 3 | MONGODB_URL=mongodb://mongo:27017/task-management 4 | LOG_LEVEL=info -------------------------------------------------------------------------------- /configs/tests.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3002 3 | HOST=localhost 4 | MONGODB_URL=mongodb://localhost:27017/task-management-test 5 | LOG_LEVEL=error -------------------------------------------------------------------------------- /infrastructure/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "datasource", 8 | "uid": "grafana" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "description": "Disha Dashboard", 19 | "editable": true, 20 | "fiscalYearStartMonth": 0, 21 | "gnetId": 3091, 22 | "graphTooltip": 0, 23 | "id": 2, 24 | "links": [], 25 | "liveNow": false, 26 | "panels": [ 27 | { 28 | "datasource": "Prometheus", 29 | "fieldConfig": { 30 | "defaults": { 31 | "color": { 32 | "mode": "thresholds" 33 | }, 34 | "mappings": [ 35 | { 36 | "options": { 37 | "match": "null", 38 | "result": { 39 | "text": "N/A" 40 | } 41 | }, 42 | "type": "special" 43 | } 44 | ], 45 | "thresholds": { 46 | "mode": "absolute", 47 | "steps": [ 48 | { 49 | "color": "green", 50 | "value": null 51 | }, 52 | { 53 | "color": "red", 54 | "value": 80 55 | } 56 | ] 57 | }, 58 | "unit": "none" 59 | }, 60 | "overrides": [] 61 | }, 62 | "gridPos": { 63 | "h": 5, 64 | "w": 4, 65 | "x": 0, 66 | "y": 0 67 | }, 68 | "id": 1, 69 | "links": [], 70 | "maxDataPoints": 100, 71 | "options": { 72 | "colorMode": "none", 73 | "graphMode": "none", 74 | "justifyMode": "auto", 75 | "orientation": "horizontal", 76 | "reduceOptions": { 77 | "calcs": [ 78 | "lastNotNull" 79 | ], 80 | "fields": "", 81 | "values": false 82 | }, 83 | "textMode": "auto" 84 | }, 85 | "pluginVersion": "10.2.0", 86 | "targets": [ 87 | { 88 | "datasource": "Prometheus", 89 | "expr": "sum(http_request_duration_seconds_count)", 90 | "format": "time_series", 91 | "intervalFactor": 2, 92 | "refId": "A", 93 | "step": 20 94 | } 95 | ], 96 | "title": "Requests", 97 | "type": "stat" 98 | }, 99 | { 100 | "datasource": "Prometheus", 101 | "fieldConfig": { 102 | "defaults": { 103 | "color": { 104 | "fixedColor": "rgb(31, 120, 193)", 105 | "mode": "fixed" 106 | }, 107 | "mappings": [ 108 | { 109 | "options": { 110 | "match": "null", 111 | "result": { 112 | "text": "N/A" 113 | } 114 | }, 115 | "type": "special" 116 | } 117 | ], 118 | "thresholds": { 119 | "mode": "absolute", 120 | "steps": [ 121 | { 122 | "color": "green", 123 | "value": null 124 | }, 125 | { 126 | "color": "red", 127 | "value": 80 128 | } 129 | ] 130 | }, 131 | "unit": "none" 132 | }, 133 | "overrides": [] 134 | }, 135 | "gridPos": { 136 | "h": 5, 137 | "w": 4, 138 | "x": 4, 139 | "y": 0 140 | }, 141 | "id": 2, 142 | "links": [], 143 | "maxDataPoints": 100, 144 | "options": { 145 | "colorMode": "none", 146 | "graphMode": "area", 147 | "justifyMode": "auto", 148 | "orientation": "horizontal", 149 | "reduceOptions": { 150 | "calcs": [ 151 | "lastNotNull" 152 | ], 153 | "fields": "", 154 | "values": false 155 | }, 156 | "textMode": "auto" 157 | }, 158 | "pluginVersion": "10.2.0", 159 | "targets": [ 160 | { 161 | "datasource": "Prometheus", 162 | "expr": "(sum(http_request_duration_seconds_bucket{le=\"1.5\",status_code=~\"^2..$\"})+ (sum(http_request_duration_seconds_bucket{le=\"10\",status_code=~\"^2..$\"})/2)) / sum(http_request_duration_seconds_bucket)", 163 | "format": "time_series", 164 | "intervalFactor": 2, 165 | "legendFormat": "", 166 | "refId": "A", 167 | "step": 20 168 | } 169 | ], 170 | "title": "Apdex Score", 171 | "type": "stat" 172 | }, 173 | { 174 | "datasource": "Prometheus", 175 | "fieldConfig": { 176 | "defaults": { 177 | "color": { 178 | "mode": "thresholds" 179 | }, 180 | "mappings": [ 181 | { 182 | "options": { 183 | "match": "null", 184 | "result": { 185 | "text": "N/A" 186 | } 187 | }, 188 | "type": "special" 189 | } 190 | ], 191 | "max": 100, 192 | "min": 0, 193 | "thresholds": { 194 | "mode": "absolute", 195 | "steps": [ 196 | { 197 | "color": "rgba(50, 172, 45, 0.97)", 198 | "value": null 199 | }, 200 | { 201 | "color": "rgba(237, 129, 40, 0.89)", 202 | "value": 10 203 | }, 204 | { 205 | "color": "rgba(245, 54, 54, 0.9)", 206 | "value": 40 207 | } 208 | ] 209 | }, 210 | "unit": "none" 211 | }, 212 | "overrides": [] 213 | }, 214 | "gridPos": { 215 | "h": 5, 216 | "w": 4, 217 | "x": 8, 218 | "y": 0 219 | }, 220 | "id": 3, 221 | "links": [], 222 | "maxDataPoints": 100, 223 | "options": { 224 | "minVizHeight": 75, 225 | "minVizWidth": 75, 226 | "orientation": "horizontal", 227 | "reduceOptions": { 228 | "calcs": [ 229 | "lastNotNull" 230 | ], 231 | "fields": "", 232 | "values": false 233 | }, 234 | "showThresholdLabels": false, 235 | "showThresholdMarkers": true 236 | }, 237 | "pluginVersion": "10.2.0", 238 | "targets": [ 239 | { 240 | "datasource": "Prometheus", 241 | "expr": "((sum (http_request_duration_seconds_count{status_code=~\"^5..$\"}) OR on () vector(0)) + (sum (http_request_duration_seconds_count{status_code=~\"^4..$\"}) OR on () vector(0))/ sum (http_request_duration_seconds_count)) * 100", 242 | "format": "time_series", 243 | "intervalFactor": 2, 244 | "legendFormat": "", 245 | "refId": "A", 246 | "step": 20 247 | } 248 | ], 249 | "title": "Errors %", 250 | "type": "gauge" 251 | }, 252 | { 253 | "datasource": "Prometheus", 254 | "fieldConfig": { 255 | "defaults": { 256 | "color": { 257 | "fixedColor": "rgb(31, 120, 193)", 258 | "mode": "fixed" 259 | }, 260 | "mappings": [ 261 | { 262 | "options": { 263 | "match": "null", 264 | "result": { 265 | "text": "N/A" 266 | } 267 | }, 268 | "type": "special" 269 | } 270 | ], 271 | "thresholds": { 272 | "mode": "absolute", 273 | "steps": [ 274 | { 275 | "color": "green", 276 | "value": null 277 | }, 278 | { 279 | "color": "red", 280 | "value": 80 281 | } 282 | ] 283 | }, 284 | "unit": "none" 285 | }, 286 | "overrides": [] 287 | }, 288 | "gridPos": { 289 | "h": 5, 290 | "w": 4, 291 | "x": 12, 292 | "y": 0 293 | }, 294 | "id": 4, 295 | "links": [], 296 | "maxDataPoints": 100, 297 | "options": { 298 | "colorMode": "none", 299 | "graphMode": "area", 300 | "justifyMode": "auto", 301 | "orientation": "horizontal", 302 | "reduceOptions": { 303 | "calcs": [ 304 | "lastNotNull" 305 | ], 306 | "fields": "", 307 | "values": false 308 | }, 309 | "textMode": "auto" 310 | }, 311 | "pluginVersion": "10.2.0", 312 | "targets": [ 313 | { 314 | "datasource": "Prometheus", 315 | "expr": "sum(max_over_time(graphql_resolver_time_count[6s])- min_over_time(graphql_resolver_time_count[24h]))", 316 | "format": "time_series", 317 | "intervalFactor": 2, 318 | "refId": "A", 319 | "step": 20 320 | } 321 | ], 322 | "title": "GraphQL Requests In 24H", 323 | "type": "stat" 324 | }, 325 | { 326 | "datasource": "Prometheus", 327 | "fieldConfig": { 328 | "defaults": { 329 | "color": { 330 | "mode": "thresholds" 331 | }, 332 | "mappings": [ 333 | { 334 | "options": { 335 | "match": "null", 336 | "result": { 337 | "text": "N/A" 338 | } 339 | }, 340 | "type": "special" 341 | } 342 | ], 343 | "max": 100, 344 | "min": 0, 345 | "thresholds": { 346 | "mode": "absolute", 347 | "steps": [ 348 | { 349 | "color": "rgba(50, 172, 45, 0.97)", 350 | "value": null 351 | }, 352 | { 353 | "color": "rgba(237, 129, 40, 0.89)", 354 | "value": 5 355 | }, 356 | { 357 | "color": "rgba(245, 54, 54, 0.9)", 358 | "value": 10 359 | } 360 | ] 361 | }, 362 | "unit": "none" 363 | }, 364 | "overrides": [] 365 | }, 366 | "gridPos": { 367 | "h": 5, 368 | "w": 4, 369 | "x": 16, 370 | "y": 0 371 | }, 372 | "id": 6, 373 | "links": [], 374 | "maxDataPoints": 100, 375 | "options": { 376 | "minVizHeight": 75, 377 | "minVizWidth": 75, 378 | "orientation": "horizontal", 379 | "reduceOptions": { 380 | "calcs": [ 381 | "lastNotNull" 382 | ], 383 | "fields": "", 384 | "values": false 385 | }, 386 | "showThresholdLabels": false, 387 | "showThresholdMarkers": true 388 | }, 389 | "pluginVersion": "10.2.0", 390 | "targets": [ 391 | { 392 | "datasource": "Prometheus", 393 | "expr": "sum(rate(pm2_cpu[1m]))", 394 | "format": "time_series", 395 | "intervalFactor": 2, 396 | "refId": "A", 397 | "step": 20 398 | } 399 | ], 400 | "title": "CPU %", 401 | "type": "gauge" 402 | }, 403 | { 404 | "datasource": "Prometheus", 405 | "description": "", 406 | "fieldConfig": { 407 | "defaults": { 408 | "color": { 409 | "fixedColor": "rgb(31, 120, 193)", 410 | "mode": "fixed" 411 | }, 412 | "mappings": [ 413 | { 414 | "options": { 415 | "match": "null", 416 | "result": { 417 | "text": "N/A" 418 | } 419 | }, 420 | "type": "special" 421 | } 422 | ], 423 | "thresholds": { 424 | "mode": "absolute", 425 | "steps": [ 426 | { 427 | "color": "green", 428 | "value": null 429 | }, 430 | { 431 | "color": "red", 432 | "value": 80 433 | } 434 | ] 435 | }, 436 | "unit": "none" 437 | }, 438 | "overrides": [] 439 | }, 440 | "gridPos": { 441 | "h": 5, 442 | "w": 4, 443 | "x": 20, 444 | "y": 0 445 | }, 446 | "id": 5, 447 | "links": [], 448 | "maxDataPoints": 100, 449 | "options": { 450 | "colorMode": "none", 451 | "graphMode": "area", 452 | "justifyMode": "auto", 453 | "orientation": "horizontal", 454 | "reduceOptions": { 455 | "calcs": [ 456 | "lastNotNull" 457 | ], 458 | "fields": "", 459 | "values": false 460 | }, 461 | "textMode": "auto" 462 | }, 463 | "pluginVersion": "10.2.0", 464 | "targets": [ 465 | { 466 | "datasource": "Prometheus", 467 | "expr": "(sum(pm2_memory)) / (sum(machine_memory_bytes))", 468 | "format": "time_series", 469 | "intervalFactor": 2, 470 | "refId": "A", 471 | "step": 20 472 | } 473 | ], 474 | "title": "Memory", 475 | "type": "stat" 476 | }, 477 | { 478 | "aliasColors": {}, 479 | "bars": false, 480 | "dashLength": 10, 481 | "dashes": false, 482 | "datasource": "Prometheus", 483 | "fill": 1, 484 | "fillGradient": 0, 485 | "gridPos": { 486 | "h": 7, 487 | "w": 12, 488 | "x": 0, 489 | "y": 5 490 | }, 491 | "hiddenSeries": false, 492 | "id": 7, 493 | "legend": { 494 | "avg": false, 495 | "current": false, 496 | "max": false, 497 | "min": false, 498 | "show": true, 499 | "total": false, 500 | "values": false 501 | }, 502 | "lines": true, 503 | "linewidth": 1, 504 | "links": [], 505 | "nullPointMode": "null", 506 | "options": { 507 | "alertThreshold": true 508 | }, 509 | "percentage": false, 510 | "pluginVersion": "10.2.0", 511 | "pointradius": 5, 512 | "points": false, 513 | "renderer": "flot", 514 | "seriesOverrides": [], 515 | "spaceLength": 10, 516 | "stack": false, 517 | "steppedLine": false, 518 | "targets": [ 519 | { 520 | "datasource": "Prometheus", 521 | "expr": "sum (irate(http_request_duration_seconds_count[1m]))", 522 | "format": "time_series", 523 | "intervalFactor": 4, 524 | "legendFormat": "Request Rate", 525 | "refId": "A", 526 | "step": 4 527 | }, 528 | { 529 | "datasource": "Prometheus", 530 | "expr": "sum (irate(http_request_duration_seconds_count{status_code=~\"^5..$\"}[1m]))", 531 | "format": "time_series", 532 | "interval": "", 533 | "intervalFactor": 4, 534 | "legendFormat": "5XX Errors Rate", 535 | "refId": "B", 536 | "step": 4 537 | }, 538 | { 539 | "datasource": "Prometheus", 540 | "expr": "sum (irate(http_request_duration_seconds_count{status_code=~\"^4..$\"}[1m]))", 541 | "format": "time_series", 542 | "intervalFactor": 2, 543 | "legendFormat": "4XX Errors Rate", 544 | "refId": "C", 545 | "step": 2 546 | }, 547 | { 548 | "datasource": "Prometheus", 549 | "expr": "sum (irate(http_request_duration_seconds_count{status_code=~\"^2..$\"}[1m]))", 550 | "format": "time_series", 551 | "intervalFactor": 2, 552 | "legendFormat": "2XX Success Rate", 553 | "refId": "D", 554 | "step": 2 555 | }, 556 | { 557 | "datasource": "Prometheus", 558 | "expr": "sum (irate(http_request_duration_seconds_count{status_code=~\"^3..$\"}[1m]))", 559 | "format": "time_series", 560 | "intervalFactor": 2, 561 | "legendFormat": "3XX Success Rate", 562 | "refId": "E", 563 | "step": 2 564 | } 565 | ], 566 | "thresholds": [], 567 | "timeRegions": [], 568 | "title": "Request and Error Rates (per sec)", 569 | "tooltip": { 570 | "shared": true, 571 | "sort": 0, 572 | "value_type": "individual" 573 | }, 574 | "type": "graph", 575 | "xaxis": { 576 | "mode": "time", 577 | "show": true, 578 | "values": [] 579 | }, 580 | "yaxes": [ 581 | { 582 | "format": "short", 583 | "logBase": 1, 584 | "show": true 585 | }, 586 | { 587 | "format": "short", 588 | "logBase": 1, 589 | "show": true 590 | } 591 | ], 592 | "yaxis": { 593 | "align": false 594 | } 595 | }, 596 | { 597 | "aliasColors": {}, 598 | "bars": true, 599 | "dashLength": 10, 600 | "dashes": false, 601 | "datasource": "Prometheus", 602 | "fill": 1, 603 | "fillGradient": 0, 604 | "gridPos": { 605 | "h": 7, 606 | "w": 12, 607 | "x": 12, 608 | "y": 5 609 | }, 610 | "hiddenSeries": false, 611 | "id": 8, 612 | "legend": { 613 | "avg": false, 614 | "current": false, 615 | "max": false, 616 | "min": false, 617 | "show": true, 618 | "total": false, 619 | "values": false 620 | }, 621 | "lines": false, 622 | "linewidth": 1, 623 | "links": [], 624 | "nullPointMode": "null", 625 | "options": { 626 | "alertThreshold": true 627 | }, 628 | "percentage": false, 629 | "pluginVersion": "10.2.0", 630 | "pointradius": 5, 631 | "points": false, 632 | "renderer": "flot", 633 | "seriesOverrides": [], 634 | "spaceLength": 10, 635 | "stack": true, 636 | "steppedLine": false, 637 | "targets": [ 638 | { 639 | "datasource": "Prometheus", 640 | "expr": "sum by(method) (rate(http_request_duration_seconds_sum[1m]))", 641 | "format": "time_series", 642 | "intervalFactor": 10, 643 | "refId": "A", 644 | "step": 10 645 | } 646 | ], 647 | "thresholds": [], 648 | "timeRegions": [], 649 | "title": "Request rate by Method (per sec)", 650 | "tooltip": { 651 | "shared": true, 652 | "sort": 0, 653 | "value_type": "individual" 654 | }, 655 | "type": "graph", 656 | "xaxis": { 657 | "mode": "time", 658 | "show": true, 659 | "values": [] 660 | }, 661 | "yaxes": [ 662 | { 663 | "format": "short", 664 | "logBase": 1, 665 | "show": true 666 | }, 667 | { 668 | "format": "short", 669 | "logBase": 1, 670 | "show": true 671 | } 672 | ], 673 | "yaxis": { 674 | "align": false 675 | } 676 | }, 677 | { 678 | "aliasColors": {}, 679 | "bars": false, 680 | "dashLength": 10, 681 | "dashes": false, 682 | "datasource": "Prometheus", 683 | "fill": 1, 684 | "fillGradient": 0, 685 | "gridPos": { 686 | "h": 7, 687 | "w": 12, 688 | "x": 0, 689 | "y": 12 690 | }, 691 | "hiddenSeries": false, 692 | "id": 10, 693 | "legend": { 694 | "avg": false, 695 | "current": false, 696 | "max": false, 697 | "min": false, 698 | "show": true, 699 | "total": false, 700 | "values": false 701 | }, 702 | "lines": true, 703 | "linewidth": 1, 704 | "links": [], 705 | "nullPointMode": "null", 706 | "options": { 707 | "alertThreshold": true 708 | }, 709 | "percentage": false, 710 | "pluginVersion": "10.2.0", 711 | "pointradius": 5, 712 | "points": false, 713 | "renderer": "flot", 714 | "seriesOverrides": [], 715 | "spaceLength": 10, 716 | "stack": false, 717 | "steppedLine": false, 718 | "targets": [ 719 | { 720 | "datasource": "Prometheus", 721 | "expr": "(\n sum(irate(http_request_duration_seconds_bucket{le=\"1.5\",status_code=~\"^2..$\"}[1m]))\n+\n (sum(irate(http_request_duration_seconds_bucket{le=\"10\",status_code=~\"^2..$\"}[1m]))/2)\n) / sum(irate(http_request_duration_seconds_bucket[1m]))", 722 | "format": "time_series", 723 | "intervalFactor": 2, 724 | "legendFormat": "", 725 | "refId": "A", 726 | "step": 2 727 | } 728 | ], 729 | "thresholds": [], 730 | "timeRegions": [], 731 | "title": "Apdex Score: target 1.5ms, tolerated: 10ms", 732 | "tooltip": { 733 | "shared": true, 734 | "sort": 0, 735 | "value_type": "individual" 736 | }, 737 | "type": "graph", 738 | "xaxis": { 739 | "mode": "time", 740 | "show": true, 741 | "values": [] 742 | }, 743 | "yaxes": [ 744 | { 745 | "format": "short", 746 | "logBase": 1, 747 | "show": true 748 | }, 749 | { 750 | "format": "short", 751 | "logBase": 1, 752 | "show": true 753 | } 754 | ], 755 | "yaxis": { 756 | "align": false 757 | } 758 | }, 759 | { 760 | "aliasColors": {}, 761 | "bars": false, 762 | "dashLength": 10, 763 | "dashes": false, 764 | "datasource": "Prometheus", 765 | "fill": 1, 766 | "fillGradient": 0, 767 | "gridPos": { 768 | "h": 7, 769 | "w": 12, 770 | "x": 12, 771 | "y": 12 772 | }, 773 | "hiddenSeries": false, 774 | "id": 9, 775 | "legend": { 776 | "alignAsTable": false, 777 | "avg": false, 778 | "current": false, 779 | "max": false, 780 | "min": false, 781 | "rightSide": false, 782 | "show": true, 783 | "total": false, 784 | "values": false 785 | }, 786 | "lines": true, 787 | "linewidth": 1, 788 | "links": [], 789 | "nullPointMode": "null", 790 | "options": { 791 | "alertThreshold": true 792 | }, 793 | "percentage": true, 794 | "pluginVersion": "10.2.0", 795 | "pointradius": 5, 796 | "points": false, 797 | "renderer": "flot", 798 | "seriesOverrides": [], 799 | "spaceLength": 10, 800 | "stack": false, 801 | "steppedLine": false, 802 | "targets": [ 803 | { 804 | "datasource": "Prometheus", 805 | "expr": "( sum(irate(http_request_duration_seconds_bucket{le=\"+inf\"}[1m])) / sum(irate(http_request_duration_seconds_bucket[1m])) ) * 100", 806 | "format": "time_series", 807 | "intervalFactor": 2, 808 | "legendFormat": "Duration < 1ms (%)", 809 | "refId": "A", 810 | "step": 2 811 | }, 812 | { 813 | "datasource": "Prometheus", 814 | "expr": "( sum(irate(http_request_duration_seconds_bucket{le=\"0.003\"}[1m])) / sum(irate(http_request_duration_seconds_bucket[1m])) ) * 100", 815 | "format": "time_series", 816 | "intervalFactor": 2, 817 | "legendFormat": "Duration < 3ms (%)", 818 | "refId": "B", 819 | "step": 2 820 | }, 821 | { 822 | "datasource": "Prometheus", 823 | "expr": "( sum(irate(http_request_duration_seconds_bucket{le=\"0.03\"}[1m])) / sum(irate(http_request_duration_seconds_bucket[1m])) ) * 100", 824 | "format": "time_series", 825 | "intervalFactor": 2, 826 | "legendFormat": "Duration < 30ms (%)", 827 | "refId": "C", 828 | "step": 2 829 | }, 830 | { 831 | "datasource": "Prometheus", 832 | "expr": "( sum(irate(http_request_duration_seconds_bucket{le=\"0.1\"}[1m])) / sum(irate(http_request_duration_seconds_bucket[1m])) ) * 100", 833 | "format": "time_series", 834 | "intervalFactor": 2, 835 | "legendFormat": "Duration < 100ms (%)", 836 | "refId": "D", 837 | "step": 2 838 | }, 839 | { 840 | "datasource": "Prometheus", 841 | "expr": "( sum(irate(http_request_duration_seconds_bucket{le=\"0.3\"}[1m])) / sum(irate(http_request_duration_seconds_bucket[1m])) ) * 100", 842 | "format": "time_series", 843 | "intervalFactor": 2, 844 | "legendFormat": "Duration < 300ms (%)", 845 | "refId": "E", 846 | "step": 2 847 | }, 848 | { 849 | "datasource": "Prometheus", 850 | "expr": "( sum(irate(http_request_duration_seconds_bucket{le=\"1.5\"}[1m])) / sum(irate(http_request_duration_seconds_bucket[1m])) ) * 100", 851 | "format": "time_series", 852 | "intervalFactor": 2, 853 | "legendFormat": "Duration < 1500ms (%)", 854 | "refId": "F", 855 | "step": 2 856 | }, 857 | { 858 | "datasource": "Prometheus", 859 | "expr": "( sum(irate(http_request_duration_seconds_bucket{le=\"10\"}[1m])) / sum(irate(http_request_duration_seconds_bucket[1m])) ) * 100", 860 | "format": "time_series", 861 | "intervalFactor": 2, 862 | "legendFormat": "Duration < 10000ms (%)", 863 | "refId": "G", 864 | "step": 2 865 | } 866 | ], 867 | "thresholds": [], 868 | "timeRegions": [], 869 | "title": "Request Duration (%)", 870 | "tooltip": { 871 | "shared": true, 872 | "sort": 0, 873 | "value_type": "individual" 874 | }, 875 | "type": "graph", 876 | "xaxis": { 877 | "mode": "time", 878 | "show": true, 879 | "values": [] 880 | }, 881 | "yaxes": [ 882 | { 883 | "format": "short", 884 | "logBase": 1, 885 | "show": true 886 | }, 887 | { 888 | "format": "short", 889 | "logBase": 1, 890 | "show": true 891 | } 892 | ], 893 | "yaxis": { 894 | "align": false 895 | } 896 | }, 897 | { 898 | "aliasColors": {}, 899 | "bars": false, 900 | "dashLength": 10, 901 | "dashes": false, 902 | "datasource": "Prometheus", 903 | "fill": 1, 904 | "fillGradient": 0, 905 | "gridPos": { 906 | "h": 7, 907 | "w": 12, 908 | "x": 0, 909 | "y": 19 910 | }, 911 | "hiddenSeries": false, 912 | "id": 11, 913 | "legend": { 914 | "avg": false, 915 | "current": false, 916 | "max": false, 917 | "min": false, 918 | "show": true, 919 | "total": false, 920 | "values": false 921 | }, 922 | "lines": true, 923 | "linewidth": 1, 924 | "links": [], 925 | "nullPointMode": "null", 926 | "options": { 927 | "alertThreshold": true 928 | }, 929 | "percentage": false, 930 | "pluginVersion": "10.2.0", 931 | "pointradius": 5, 932 | "points": false, 933 | "renderer": "flot", 934 | "seriesOverrides": [], 935 | "spaceLength": 10, 936 | "stack": false, 937 | "steppedLine": false, 938 | "targets": [ 939 | { 940 | "datasource": "Prometheus", 941 | "expr": "topk(10,max_over_time(http_request_duration_seconds_count[6s])- min_over_time(http_request_duration_seconds_count[24h]))", 942 | "format": "time_series", 943 | "intervalFactor": 2, 944 | "legendFormat": "", 945 | "refId": "A", 946 | "step": 2 947 | } 948 | ], 949 | "thresholds": [], 950 | "timeRegions": [], 951 | "title": "Top 10 API calls (by path) In Last 24 hours", 952 | "tooltip": { 953 | "shared": true, 954 | "sort": 0, 955 | "value_type": "individual" 956 | }, 957 | "transparent": true, 958 | "type": "graph", 959 | "xaxis": { 960 | "mode": "time", 961 | "show": true, 962 | "values": [] 963 | }, 964 | "yaxes": [ 965 | { 966 | "format": "short", 967 | "logBase": 1, 968 | "show": true 969 | }, 970 | { 971 | "format": "short", 972 | "logBase": 1, 973 | "show": true 974 | } 975 | ], 976 | "yaxis": { 977 | "align": false 978 | } 979 | }, 980 | { 981 | "aliasColors": {}, 982 | "bars": true, 983 | "dashLength": 10, 984 | "dashes": false, 985 | "datasource": "Prometheus", 986 | "fill": 1, 987 | "fillGradient": 0, 988 | "gridPos": { 989 | "h": 7, 990 | "w": 12, 991 | "x": 12, 992 | "y": 19 993 | }, 994 | "hiddenSeries": false, 995 | "id": 12, 996 | "legend": { 997 | "alignAsTable": false, 998 | "avg": false, 999 | "current": false, 1000 | "hideEmpty": false, 1001 | "hideZero": false, 1002 | "max": false, 1003 | "min": false, 1004 | "rightSide": false, 1005 | "show": true, 1006 | "total": false, 1007 | "values": false 1008 | }, 1009 | "lines": false, 1010 | "linewidth": 1, 1011 | "links": [], 1012 | "nullPointMode": "null", 1013 | "options": { 1014 | "alertThreshold": true 1015 | }, 1016 | "percentage": false, 1017 | "pluginVersion": "10.2.0", 1018 | "pointradius": 5, 1019 | "points": false, 1020 | "renderer": "flot", 1021 | "seriesOverrides": [], 1022 | "spaceLength": 10, 1023 | "stack": true, 1024 | "steppedLine": false, 1025 | "targets": [ 1026 | { 1027 | "datasource": "Prometheus", 1028 | "expr": "topk(10,max_over_time(http_request_duration_seconds_count{code=~\"^5..$\"}[6s])- min_over_time(http_request_duration_seconds_count{code=~\"^5..$\"}[24h]))", 1029 | "format": "time_series", 1030 | "intervalFactor": 2, 1031 | "legendFormat": "", 1032 | "refId": "A", 1033 | "step": 2 1034 | }, 1035 | { 1036 | "datasource": "Prometheus", 1037 | "expr": "topk(10,max_over_time(http_request_duration_seconds_count{status_code=~\"^[4,5]..$\"}[6s])- min_over_time(http_request_duration_seconds_count{status_code=~\"^[4,5]..$\"}[24h]))", 1038 | "format": "time_series", 1039 | "intervalFactor": 2, 1040 | "legendFormat": "", 1041 | "refId": "B", 1042 | "step": 2 1043 | } 1044 | ], 1045 | "thresholds": [], 1046 | "timeRegions": [], 1047 | "title": "Top 10 API Error Calls ( 5XX and 4XX, by path)", 1048 | "tooltip": { 1049 | "shared": true, 1050 | "sort": 0, 1051 | "value_type": "individual" 1052 | }, 1053 | "transparent": true, 1054 | "type": "graph", 1055 | "xaxis": { 1056 | "mode": "time", 1057 | "show": true, 1058 | "values": [] 1059 | }, 1060 | "yaxes": [ 1061 | { 1062 | "format": "short", 1063 | "logBase": 1, 1064 | "show": true 1065 | }, 1066 | { 1067 | "format": "short", 1068 | "logBase": 1, 1069 | "show": true 1070 | } 1071 | ], 1072 | "yaxis": { 1073 | "align": false 1074 | } 1075 | }, 1076 | { 1077 | "datasource": "Loki", 1078 | "gridPos": { 1079 | "h": 9, 1080 | "w": 24, 1081 | "x": 0, 1082 | "y": 26 1083 | }, 1084 | "id": 13, 1085 | "options": { 1086 | "dedupStrategy": "none", 1087 | "enableLogDetails": true, 1088 | "prettifyLogMessage": false, 1089 | "showCommonLabels": false, 1090 | "showLabels": false, 1091 | "showTime": true, 1092 | "sortOrder": "Descending", 1093 | "wrapLogMessage": true 1094 | }, 1095 | "targets": [ 1096 | { 1097 | "datasource": { 1098 | "type": "loki", 1099 | "uid": "a726ee74-7a42-4dce-acc3-d5c48bd6d76d" 1100 | }, 1101 | "editorMode": "builder", 1102 | "expr": "{filename=\"/var/log/app.log\"} |= ``", 1103 | "queryType": "range", 1104 | "refId": "A" 1105 | } 1106 | ], 1107 | "title": "Logs", 1108 | "type": "logs" 1109 | } 1110 | ], 1111 | "refresh": "10s", 1112 | "schemaVersion": 38, 1113 | "tags": [], 1114 | "templating": { 1115 | "list": [] 1116 | }, 1117 | "time": { 1118 | "from": "now-30m", 1119 | "to": "now" 1120 | }, 1121 | "timepicker": { 1122 | "refresh_intervals": [ 1123 | "10s", 1124 | "30s", 1125 | "1m", 1126 | "5m", 1127 | "15m", 1128 | "30m", 1129 | "1h", 1130 | "2h", 1131 | "1d" 1132 | ], 1133 | "time_options": [ 1134 | "5m", 1135 | "15m", 1136 | "1h", 1137 | "6h", 1138 | "12h", 1139 | "24h", 1140 | "2d", 1141 | "7d", 1142 | "30d" 1143 | ] 1144 | }, 1145 | "timezone": "", 1146 | "title": "App", 1147 | "uid": "xTd0KUNMk", 1148 | "version": 2, 1149 | "weekStart": "" 1150 | } -------------------------------------------------------------------------------- /infrastructure/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | 4 | scrape_configs: 5 | - job_name: "prometheus" 6 | static_configs: 7 | - targets: ["localhost:9090"] 8 | - job_name: "app" 9 | metrics_path: "/metrics" 10 | static_configs: 11 | - targets: ["app:80"] 12 | -------------------------------------------------------------------------------- /infrastructure/promtail.yml: -------------------------------------------------------------------------------- 1 | server: 2 | http_listen_port: 9080 3 | grpc_listen_port: 0 4 | 5 | positions: 6 | filename: /tmp/positions.yaml 7 | 8 | clients: 9 | - url: http://loki:3100/loki/api/v1/push 10 | 11 | scrape_configs: 12 | - job_name: "__service__name__" 13 | pipeline_stages: 14 | - json: 15 | expressions: 16 | level: level 17 | message: message 18 | timestamp: timestamp 19 | service: service 20 | meta: 21 | - labels: 22 | level: 23 | - timestamp: 24 | source: timestamp 25 | format: RFC3339Nano 26 | static_configs: 27 | - targets: 28 | - localhost 29 | labels: 30 | job: "__service_name__" 31 | instance: "__instance_name__" 32 | env: "production" 33 | __path__: /var/log/app.log 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "task-management", 3 | "version": "1.0.0", 4 | "main": "src/index.js", 5 | "repository": "https://github.com/misikdmytro/task-management.git", 6 | "author": "Dmytro Misik", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "node src/index.js", 10 | "dev": "nodemon src/index.js", 11 | "test": "jest", 12 | "lint": "eslint . --ext .js" 13 | }, 14 | "dependencies": { 15 | "compression": "^1.7.4", 16 | "dotenv": "^16.3.1", 17 | "express": "^4.18.2", 18 | "express-mongo-sanitize": "^2.2.0", 19 | "express-prom-bundle": "^6.6.0", 20 | "joi": "^17.11.0", 21 | "mongoose": "^8.0.0", 22 | "on-headers": "^1.0.2", 23 | "prom-client": "^15.0.0", 24 | "swagger-jsdoc": "^6.2.8", 25 | "swagger-ui-express": "^5.0.0", 26 | "winston": "^3.11.0", 27 | "xss-clean": "^0.1.4" 28 | }, 29 | "devDependencies": { 30 | "@faker-js/faker": "^8.2.0", 31 | "eslint": "^7.32.0 || ^8.2.0", 32 | "eslint-config-airbnb-base": "^15.0.0", 33 | "eslint-plugin-import": "^2.25.2", 34 | "jest": "^29.7.0", 35 | "node-fetch": "^2.7.0", 36 | "nodemon": "^3.0.1", 37 | "prettier": "^2.5.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const mongoSanitize = require('express-mongo-sanitize'); 3 | const xss = require('xss-clean'); 4 | const compression = require('compression'); 5 | const prometheus = require('express-prom-bundle'); 6 | const docs = require('./routes/docs'); 7 | const health = require('./routes/health'); 8 | const v1 = require('./routes/v1'); 9 | const { errorHandler } = require('./middlewares/error'); 10 | const logger = require('./middlewares/logger'); 11 | 12 | const app = express(); 13 | 14 | // service 15 | app.use(prometheus()); 16 | app.use(express.json()); 17 | 18 | // logger 19 | app.use(logger); 20 | 21 | // sanitize request data 22 | app.use(xss()); 23 | app.use(mongoSanitize()); 24 | 25 | // compress all responses 26 | app.use(compression()); 27 | 28 | // docs 29 | app.use('/docs', docs); 30 | 31 | // health API 32 | app.use('/health', health); 33 | 34 | // V1 API 35 | app.use('/v1', v1); 36 | 37 | // error handler 38 | app.use(errorHandler); 39 | 40 | module.exports = app; 41 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const Joi = require('joi'); 3 | 4 | const envVarsSchema = Joi.object() 5 | .keys({ 6 | NODE_ENV: Joi.string().valid('production', 'development', 'test').required(), 7 | PORT: Joi.number().default(3000), 8 | MONGODB_URL: Joi.string().required().description('Mongo DB url'), 9 | LOG_LEVEL: Joi.string().valid('error', 'warn', 'info', 'debug').default('info'), 10 | }) 11 | .unknown(); 12 | 13 | function createConfig(configPath) { 14 | dotenv.config({ path: configPath }); 15 | 16 | const { value: envVars, error } = envVarsSchema 17 | .prefs({ errors: { label: 'key' } }) 18 | .validate(process.env); 19 | 20 | if (error) { 21 | throw new Error(`Config validation error: ${error.message}`); 22 | } 23 | 24 | return { 25 | env: envVars.NODE_ENV, 26 | port: envVars.PORT, 27 | mongo: { 28 | url: envVars.MONGODB_URL, 29 | }, 30 | logLevel: envVars.LOG_LEVEL, 31 | }; 32 | } 33 | 34 | module.exports = { 35 | createConfig, 36 | }; 37 | -------------------------------------------------------------------------------- /src/config/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const path = require('path'); 3 | 4 | const logger = winston.createLogger({ 5 | defaultMeta: { service: 'task-management' }, 6 | format: winston.format.combine(winston.format.timestamp(), winston.format.json()), 7 | transports: [], 8 | }); 9 | 10 | module.exports = logger; 11 | 12 | function init({ env, logLevel: level }) { 13 | logger.add( 14 | new winston.transports.Console({ 15 | level, 16 | silent: env === 'test', 17 | }), 18 | ); 19 | 20 | if (env !== 'development') { 21 | logger.add( 22 | new winston.transports.File({ 23 | level, 24 | filename: path.join(__dirname, '../../logs/app.log'), 25 | }), 26 | ); 27 | } 28 | } 29 | 30 | function destroy() { 31 | logger.clear(); 32 | logger.close(); 33 | } 34 | 35 | module.exports.init = init; 36 | module.exports.destroy = destroy; 37 | -------------------------------------------------------------------------------- /src/controllers/health.js: -------------------------------------------------------------------------------- 1 | const { healthcheck } = require('../services/health'); 2 | 3 | function health(req, res) { 4 | const status = healthcheck() ? 200 : 500; 5 | res.status(status).send(); 6 | } 7 | 8 | module.exports = { 9 | health, 10 | }; 11 | -------------------------------------------------------------------------------- /src/controllers/task.js: -------------------------------------------------------------------------------- 1 | const taskService = require('../services/task'); 2 | const catchAsync = require('../middlewares/catchAsync'); 3 | 4 | function toDto(task) { 5 | const { 6 | id, name, description, status, createdAt, updatedAt, 7 | } = task; 8 | 9 | return { 10 | id, 11 | name, 12 | description, 13 | status, 14 | createdAt, 15 | updatedAt, 16 | }; 17 | } 18 | 19 | const getTaskById = catchAsync(async (req, res) => { 20 | const result = await taskService.getTaskById(req.params.id); 21 | 22 | if (result) { 23 | res.status(200).json({ success: true, task: toDto(result) }); 24 | } else { 25 | res.status(404).json({ success: false, message: 'task not found' }); 26 | } 27 | }); 28 | 29 | const createTask = catchAsync(async (req, res) => { 30 | const result = await taskService.createTask(req.body.name, req.body.description); 31 | 32 | res.status(201).json({ 33 | success: true, 34 | task: toDto(result), 35 | }); 36 | }); 37 | 38 | const updateTaskById = catchAsync(async (req, res) => { 39 | const result = await taskService.updateTaskById(req.params.id, req.body); 40 | if (result.error) { 41 | switch (result.code) { 42 | case taskService.errorCodes.AT_LEAST_ONE_UPDATE_REQUIRED_CODE: 43 | res.status(400).json({ success: false, message: 'at least one update required' }); 44 | return; 45 | case taskService.errorCodes.INVALID_STATUS_CODE: 46 | res.status(400).json({ success: false, message: 'invalid status' }); 47 | return; 48 | case taskService.errorCodes.INVALID_STATUS_TRANSITION_CODE: 49 | res.status(404).json({ success: false, message: 'task not found' }); 50 | return; 51 | case taskService.errorCodes.TASK_NOT_FOUND_CODE: 52 | res.status(400).json({ success: false, message: result.error }); 53 | return; 54 | case taskService.errorCodes.CONCURRENCY_ERROR_CODE: 55 | res.status(500).json({ success: false, message: 'concurrency error' }); 56 | return; 57 | default: 58 | res.status(500).json({ success: false, message: 'internal server error' }); 59 | return; 60 | } 61 | } 62 | 63 | res.status(200).json({ 64 | success: true, 65 | task: toDto(result), 66 | }); 67 | }); 68 | 69 | module.exports = { 70 | getTaskById, 71 | createTask, 72 | updateTaskById, 73 | }; 74 | -------------------------------------------------------------------------------- /src/db/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const logger = require('../config/logger'); 3 | 4 | let mongoUrl; 5 | async function init({ mongo: { url } }) { 6 | mongoUrl = url; 7 | 8 | try { 9 | await mongoose.connect(mongoUrl); 10 | } catch (err) { 11 | logger.error('error in mongo connection', { err }); 12 | setTimeout(init, 5000); 13 | } 14 | } 15 | 16 | const db = mongoose.connection; 17 | 18 | function destroy() { 19 | db.removeAllListeners(); 20 | return mongoose.disconnect(); 21 | } 22 | 23 | db.on('connected', () => { 24 | logger.info('mongo connected'); 25 | }); 26 | 27 | db.on('error', (error) => { 28 | logger.error('error in mongo connection', { error }); 29 | mongoose.disconnect(); 30 | }); 31 | 32 | db.on('disconnected', () => { 33 | logger.info('mongo disconnected'); 34 | init({ mongo: { url: mongoUrl } }); 35 | }); 36 | 37 | module.exports = { 38 | init, 39 | destroy, 40 | }; 41 | -------------------------------------------------------------------------------- /src/docs/definition.yml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | CreateTask: 4 | type: object 5 | required: 6 | - name 7 | properties: 8 | name: 9 | type: string 10 | description: The task name 11 | example: Buy milk 12 | description: 13 | type: string 14 | description: The task description 15 | example: Go to the store and buy some milk 16 | 17 | UpdateTask: 18 | type: object 19 | properties: 20 | name: 21 | type: string 22 | description: The task name 23 | example: Buy milk 24 | description: 25 | type: string 26 | description: The task description 27 | example: Go to the store and buy some milk 28 | status: 29 | type: string 30 | description: The task status 31 | example: new 32 | enum: 33 | - new 34 | - active 35 | - completed 36 | - canceled 37 | 38 | TaskResult: 39 | type: object 40 | required: 41 | - success 42 | properties: 43 | success: 44 | type: boolean 45 | description: The result status 46 | example: true 47 | message: 48 | type: string 49 | description: The result message 50 | example: Task created successfully 51 | task: 52 | $ref: "#/components/schemas/Task" 53 | 54 | Task: 55 | type: object 56 | required: 57 | - id 58 | - name 59 | - createdAt 60 | - status 61 | properties: 62 | id: 63 | type: string 64 | description: The task ID 65 | example: 1 66 | name: 67 | type: string 68 | description: The task name 69 | example: Buy milk 70 | description: 71 | type: string 72 | description: The task description 73 | example: Go to the store and buy some milk 74 | createdAt: 75 | type: string 76 | format: date-time 77 | description: The task creation date 78 | example: 2021-01-01T00:00:00.000Z 79 | updatedAt: 80 | type: string 81 | format: date-time 82 | description: The task update date 83 | example: 2021-01-01T00:00:00.000Z 84 | status: 85 | type: string 86 | description: The task status 87 | example: new 88 | enum: 89 | - new 90 | - active 91 | - completed 92 | - canceled 93 | -------------------------------------------------------------------------------- /src/docs/swagger.js: -------------------------------------------------------------------------------- 1 | const { version } = require('../../package.json'); 2 | 3 | const swaggerDefinition = { 4 | openapi: '3.0.0', 5 | info: { 6 | title: 'Task API', 7 | version, 8 | description: 'Task API', 9 | license: { 10 | name: 'MIT', 11 | }, 12 | contact: { 13 | name: 'Task API', 14 | }, 15 | }, 16 | }; 17 | 18 | module.exports = swaggerDefinition; 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const db = require('./db'); 3 | const app = require('./app'); 4 | const { createConfig } = require('./config/config'); 5 | const logger = require('./config/logger'); 6 | 7 | async function run() { 8 | const configPath = path.join(__dirname, '../configs/.env'); 9 | const config = createConfig(configPath); 10 | 11 | logger.init(config); 12 | await db.init(config); 13 | const server = app.listen(config.port, () => { 14 | logger.info('app started', { port: config.port }); 15 | }); 16 | 17 | const exitHandler = () => { 18 | if (server) { 19 | server.close(() => { 20 | logger.info('server closed'); 21 | process.exit(1); 22 | }); 23 | } else { 24 | process.exit(1); 25 | } 26 | }; 27 | 28 | const unexpectedErrorHandler = (error) => { 29 | logger.error('unhandled error', { error }); 30 | exitHandler(); 31 | }; 32 | 33 | process.on('uncaughtException', unexpectedErrorHandler); 34 | process.on('unhandledRejection', unexpectedErrorHandler); 35 | 36 | process.on('SIGTERM', () => { 37 | logger.info('SIGTERM received'); 38 | if (server) { 39 | server.close(); 40 | } 41 | }); 42 | } 43 | 44 | run(); 45 | -------------------------------------------------------------------------------- /src/middlewares/catchAsync.js: -------------------------------------------------------------------------------- 1 | function catchAsync(fn) { 2 | return (req, res, next) => { 3 | Promise.resolve(fn(req, res, next)).catch(next); 4 | }; 5 | } 6 | 7 | module.exports = catchAsync; 8 | -------------------------------------------------------------------------------- /src/middlewares/error.js: -------------------------------------------------------------------------------- 1 | const logger = require('../config/logger'); 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | function errorHandler(error, req, res, next) { 5 | logger.error('unhandled error', { error }); 6 | res.status(500).json({ success: false }); 7 | } 8 | 9 | module.exports = { 10 | errorHandler, 11 | }; 12 | -------------------------------------------------------------------------------- /src/middlewares/logger.js: -------------------------------------------------------------------------------- 1 | const onHeaders = require('on-headers'); 2 | const logger = require('../config/logger'); 3 | 4 | function loggerMiddleware(req, res, next) { 5 | const started = new Date(); 6 | logger.debug('request received', { url: req.url, method: req.method, body: req.body }); 7 | 8 | onHeaders(res, () => { 9 | logger.info('response sent', { 10 | url: req.url, 11 | method: req.method, 12 | statusCode: res.statusCode, 13 | duration: new Date() - started, 14 | }); 15 | }); 16 | 17 | next(); 18 | } 19 | 20 | module.exports = loggerMiddleware; 21 | -------------------------------------------------------------------------------- /src/middlewares/validate.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const pick = require('../utils/pick'); 3 | 4 | function validate(schema) { 5 | return (req, res, next) => { 6 | const validSchema = pick(schema, ['params', 'query', 'body']); 7 | const object = pick(req, Object.keys(validSchema)); 8 | const { value, error } = Joi.compile(validSchema) 9 | .prefs({ errors: { label: 'key' }, abortEarly: false }) 10 | .validate(object); 11 | 12 | if (error) { 13 | const errorMessage = error.details.map((details) => details.message).join(', '); 14 | res.status(400).json({ success: false, message: errorMessage }); 15 | return; 16 | } 17 | 18 | Object.assign(req, value); 19 | next(); 20 | }; 21 | } 22 | 23 | module.exports = validate; 24 | -------------------------------------------------------------------------------- /src/models/task.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const { Schema } = mongoose; 4 | 5 | const TaskSchema = new Schema( 6 | { 7 | name: { 8 | type: String, 9 | required: true, 10 | }, 11 | description: { 12 | type: String, 13 | required: false, 14 | }, 15 | status: { 16 | type: String, 17 | enum: ['new', 'active', 'completed', 'cancelled'], 18 | default: 'new', 19 | }, 20 | createdAt: { 21 | type: Date, 22 | default: Date.now, 23 | }, 24 | updatedAt: Date, 25 | }, 26 | { optimisticConcurrency: true }, 27 | ); 28 | 29 | module.exports = mongoose.model('task', TaskSchema); 30 | -------------------------------------------------------------------------------- /src/routes/docs/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const swaggerJsdoc = require('swagger-jsdoc'); 3 | const swaggerUi = require('swagger-ui-express'); 4 | const swaggerDefinition = require('../../docs/swagger'); 5 | 6 | const router = express.Router(); 7 | 8 | const specs = swaggerJsdoc({ 9 | swaggerDefinition, 10 | apis: ['src/docs/*.yml', 'src/routes/**/*.js'], 11 | }); 12 | 13 | router.use('/', swaggerUi.serve); 14 | router.get( 15 | '/', 16 | swaggerUi.setup(specs, { 17 | explorer: true, 18 | }), 19 | ); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /src/routes/health/index.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | const { health } = require('../../controllers/health'); 3 | 4 | const router = Router(); 5 | 6 | router.get('/', health); 7 | 8 | module.exports = router; 9 | 10 | /** 11 | * @swagger 12 | * tags: 13 | * name: Health 14 | * description: Health check 15 | * /health: 16 | * get: 17 | * summary: Health check 18 | * tags: [Health] 19 | * description: Health check 20 | * responses: 21 | * 200: 22 | * description: Health check 23 | * 500: 24 | * description: Internal Server Error 25 | */ 26 | -------------------------------------------------------------------------------- /src/routes/v1/index.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | const tasksRouter = require('./tasks'); 3 | 4 | const router = Router(); 5 | 6 | router.use('/tasks', tasksRouter); 7 | 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /src/routes/v1/tasks/index.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | const taskController = require('../../../controllers/task'); 3 | const taskValidation = require('../../../validation/task'); 4 | const validate = require('../../../middlewares/validate'); 5 | 6 | const router = Router(); 7 | 8 | router.get('/:id', validate(taskValidation.getTaskById), taskController.getTaskById); 9 | router.put('/', validate(taskValidation.createTask), taskController.createTask); 10 | router.post('/:id', validate(taskValidation.updateTaskById), taskController.updateTaskById); 11 | 12 | module.exports = router; 13 | 14 | /** 15 | * @swagger 16 | * tags: 17 | * name: Tasks 18 | * description: Task management and retrieval 19 | * /v1/tasks/{id}: 20 | * get: 21 | * summary: Get a task by id 22 | * tags: [Tasks] 23 | * description: Get a task by id 24 | * parameters: 25 | * - in: path 26 | * name: id 27 | * schema: 28 | * type: string 29 | * required: true 30 | * description: Task id 31 | * example: 5f0a3d9a3e06e52f3c7a6d5c 32 | * responses: 33 | * 200: 34 | * description: Task Retrieved 35 | * content: 36 | * application/json: 37 | * schema: 38 | * $ref: '#/components/schemas/TaskResult' 39 | * 404: 40 | * description: Task not found 41 | * content: 42 | * application/json: 43 | * schema: 44 | * $ref: '#/components/schemas/TaskResult' 45 | * 500: 46 | * description: Internal Server Error 47 | * post: 48 | * summary: Update a task by id 49 | * tags: [Tasks] 50 | * description: Update a task by id 51 | * parameters: 52 | * - in: path 53 | * name: id 54 | * schema: 55 | * type: string 56 | * required: true 57 | * description: Task id 58 | * example: 5f0a3d9a3e06e52f3c7a6d5c 59 | * requestBody: 60 | * required: true 61 | * content: 62 | * application/json: 63 | * schema: 64 | * $ref: '#/components/schemas/UpdateTask' 65 | * responses: 66 | * 200: 67 | * description: Task Updated 68 | * content: 69 | * application/json: 70 | * schema: 71 | * $ref: '#/components/schemas/TaskResult' 72 | * 404: 73 | * description: Task not found 74 | * content: 75 | * application/json: 76 | * schema: 77 | * $ref: '#/components/schemas/TaskResult' 78 | * 500: 79 | * description: Internal Server Error 80 | * /v1/tasks: 81 | * put: 82 | * summary: Create a task 83 | * tags: [Tasks] 84 | * description: Create a task 85 | * requestBody: 86 | * required: true 87 | * content: 88 | * application/json: 89 | * schema: 90 | * $ref: '#/components/schemas/CreateTask' 91 | * responses: 92 | * 201: 93 | * description: Task Created 94 | * content: 95 | * application/json: 96 | * schema: 97 | * $ref: '#/components/schemas/TaskResult' 98 | * 500: 99 | * description: Internal Server Error 100 | */ 101 | -------------------------------------------------------------------------------- /src/services/health.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | function healthcheck() { 4 | return mongoose.connection.readyState === 1; 5 | } 6 | 7 | module.exports = { 8 | healthcheck, 9 | }; 10 | -------------------------------------------------------------------------------- /src/services/task.js: -------------------------------------------------------------------------------- 1 | const logger = require('../config/logger'); 2 | const Task = require('../models/task'); 3 | 4 | function getTaskById(id) { 5 | return Task.findById(id); 6 | } 7 | 8 | function createTask(name, description) { 9 | return Task.create({ name, description }); 10 | } 11 | 12 | const availableUpdates = { 13 | new: ['active', 'cancelled'], 14 | active: ['completed', 'cancelled'], 15 | completed: [], 16 | cancelled: [], 17 | }; 18 | 19 | const AT_LEAST_ONE_UPDATE_REQUIRED_CODE = 0; 20 | const INVALID_STATUS_CODE = 1; 21 | const INVALID_STATUS_TRANSITION_CODE = 2; 22 | const TASK_NOT_FOUND_CODE = 3; 23 | const CONCURRENCY_ERROR_CODE = 4; 24 | 25 | async function updateTaskById(id, { name, description, status }) { 26 | if (!name && !description && !status) { 27 | return { error: 'at least one update required', code: AT_LEAST_ONE_UPDATE_REQUIRED_CODE }; 28 | } 29 | 30 | if (status && !(status in availableUpdates)) { 31 | return { error: 'invalid status', code: INVALID_STATUS_CODE }; 32 | } 33 | 34 | for (let retry = 0; retry < 3; retry += 1) { 35 | // eslint-disable-next-line no-await-in-loop 36 | const task = await Task.findById(id); 37 | if (!task) { 38 | return { error: 'task not found', code: INVALID_STATUS_TRANSITION_CODE }; 39 | } 40 | 41 | if (status) { 42 | const allowedStatuses = availableUpdates[task.status]; 43 | if (!allowedStatuses.includes(status)) { 44 | return { 45 | error: `cannot update from '${task.status}' to '${status}'`, 46 | code: TASK_NOT_FOUND_CODE, 47 | }; 48 | } 49 | } 50 | 51 | task.status = status ?? task.status; 52 | task.name = name ?? task.name; 53 | task.description = description ?? task.description; 54 | task.updatedAt = Date.now(); 55 | 56 | try { 57 | // eslint-disable-next-line no-await-in-loop 58 | await task.save(); 59 | } catch (error) { 60 | logger.warn('error during save', { error }); 61 | if (error.name === 'VersionError') { 62 | // eslint-disable-next-line no-continue 63 | continue; 64 | } 65 | } 66 | 67 | return task; 68 | } 69 | 70 | return { error: 'concurrency error', code: CONCURRENCY_ERROR_CODE }; 71 | } 72 | 73 | module.exports = { 74 | getTaskById, 75 | createTask, 76 | updateTaskById, 77 | 78 | errorCodes: { 79 | AT_LEAST_ONE_UPDATE_REQUIRED_CODE, 80 | INVALID_STATUS_CODE, 81 | INVALID_STATUS_TRANSITION_CODE, 82 | TASK_NOT_FOUND_CODE, 83 | CONCURRENCY_ERROR_CODE, 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /src/utils/pick.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create an object composed of the picked object properties 3 | * @param {Object} object 4 | * @param {string[]} keys 5 | * @returns {Object} 6 | */ 7 | function pick(object, keys) { 8 | return keys.reduce((obj, key) => { 9 | if (object && Object.prototype.hasOwnProperty.call(object, key)) { 10 | return { ...obj, [key]: object[key] }; 11 | } 12 | 13 | return obj; 14 | }, {}); 15 | } 16 | 17 | module.exports = pick; 18 | -------------------------------------------------------------------------------- /src/validation/task.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const objectId = Joi.string().regex(/^[0-9a-fA-F]{24}$/); 4 | 5 | const getTaskById = { 6 | params: Joi.object().keys({ 7 | id: objectId.required(), 8 | }), 9 | }; 10 | 11 | const createTask = { 12 | body: Joi.object().keys({ 13 | name: Joi.string().required(), 14 | description: Joi.string().optional(), 15 | }), 16 | }; 17 | 18 | const updateTaskById = { 19 | params: Joi.object().keys({ 20 | id: objectId.required(), 21 | }), 22 | body: Joi.object().keys({ 23 | name: Joi.string().optional(), 24 | description: Joi.string().optional(), 25 | status: Joi.string().valid('new', 'active', 'completed', 'cancelled').optional(), 26 | }), 27 | }; 28 | 29 | module.exports = { 30 | getTaskById, 31 | createTask, 32 | updateTaskById, 33 | }; 34 | -------------------------------------------------------------------------------- /tests/integration/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const app = require('../../src/app'); 3 | const db = require('../../src/db'); 4 | const { createConfig } = require('../../src/config/config'); 5 | const logger = require('../../src/config/logger'); 6 | 7 | const setupServer = () => { 8 | let server; 9 | 10 | const configPath = path.join(__dirname, '../../configs/tests.env'); 11 | const config = createConfig(configPath); 12 | 13 | beforeAll(async () => { 14 | logger.init(config); 15 | await db.init(config); 16 | 17 | await new Promise((resolve) => { 18 | server = app.listen(config.port, () => { 19 | resolve(); 20 | }); 21 | }); 22 | }); 23 | 24 | afterAll(async () => { 25 | await new Promise((resolve) => { 26 | server.close(() => { 27 | resolve(); 28 | }); 29 | }); 30 | 31 | await db.destroy(); 32 | logger.destroy(); 33 | }); 34 | }; 35 | 36 | module.exports = { 37 | setupServer, 38 | }; 39 | -------------------------------------------------------------------------------- /tests/integration/task.test.js: -------------------------------------------------------------------------------- 1 | const { faker } = require('@faker-js/faker'); 2 | const fetch = require('node-fetch'); 3 | const { setupServer } = require('./server'); 4 | 5 | setupServer(); 6 | 7 | describe('Task', () => { 8 | const baseUrl = `http://${process.env.HOST}:${process.env.PORT}/v1/tasks`; 9 | 10 | describe('get', () => { 11 | it('should return 404', async () => { 12 | const response = await fetch(`${baseUrl}/${faker.database.mongodbObjectId()}`); 13 | expect(response.status).toEqual(404); 14 | 15 | const result = await response.json(); 16 | expect(result).toEqual({ success: false, message: 'task not found' }); 17 | }); 18 | 19 | describe('should return 400', () => { 20 | const data = [ 21 | { 22 | name: 'number', 23 | id: '1234567890', 24 | }, 25 | { 26 | name: 'uuid', 27 | id: '123e4567-e89b-12d3-a456-426614174000', 28 | }, 29 | { 30 | name: 'string', 31 | id: 'abc', 32 | }, 33 | ]; 34 | 35 | data.forEach(({ name, id }) => { 36 | it(name, async () => { 37 | const response = await fetch(`${baseUrl}/${id}`); 38 | expect(response.status).toEqual(400); 39 | 40 | const result = await response.json(); 41 | expect(result).toEqual({ 42 | success: false, 43 | message: `"id" with value "${id}" fails to match the required pattern: /^[0-9a-fA-F]{24}$/`, 44 | }); 45 | }); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('create & get', () => { 51 | describe('should create & return a task', () => { 52 | const data = [ 53 | { 54 | name: 'english', 55 | taskName: 'Task 1', 56 | description: 'Task 1 description', 57 | }, 58 | { 59 | name: 'japanese', 60 | taskName: 'タスク 1', 61 | description: 'タスク 1 説明', 62 | }, 63 | { 64 | name: 'chinese', 65 | taskName: '任务 1', 66 | description: '任务 1 描述', 67 | }, 68 | { 69 | name: 'emoji', 70 | taskName: '👍', 71 | description: '👍', 72 | }, 73 | ]; 74 | 75 | data.forEach(({ name, taskName, description }) => { 76 | it(name, async () => { 77 | let response = await fetch(baseUrl, { 78 | method: 'put', 79 | body: JSON.stringify({ 80 | name: taskName, 81 | description, 82 | }), 83 | headers: { 'Content-Type': 'application/json' }, 84 | }); 85 | 86 | expect(response.status).toEqual(201); 87 | 88 | const result = await response.json(); 89 | 90 | expect(result).toEqual({ 91 | success: true, 92 | task: { 93 | id: expect.any(String), 94 | name: taskName, 95 | description, 96 | status: 'new', 97 | createdAt: expect.any(String), 98 | }, 99 | }); 100 | 101 | expect(new Date() - new Date(result.task.createdAt)).toBeLessThan(1000); 102 | 103 | response = await fetch(`${baseUrl}/${result.task.id}`); 104 | 105 | expect(response.status).toEqual(200); 106 | 107 | const result2 = await response.json(); 108 | 109 | expect(result2).toEqual({ 110 | success: true, 111 | task: { 112 | id: result.task.id, 113 | name: taskName, 114 | description, 115 | status: 'new', 116 | createdAt: result.task.createdAt, 117 | }, 118 | }); 119 | }); 120 | }); 121 | }); 122 | }); 123 | 124 | describe('create & update', () => { 125 | describe('update', () => { 126 | it('should return 404', async () => { 127 | const response = await fetch(`${baseUrl}/${faker.database.mongodbObjectId()}`, { 128 | method: 'post', 129 | body: JSON.stringify({ 130 | name: faker.lorem.word(), 131 | description: faker.lorem.sentence(), 132 | }), 133 | headers: { 'Content-Type': 'application/json' }, 134 | }); 135 | 136 | expect(response.status).toEqual(404); 137 | 138 | const result = await response.json(); 139 | 140 | expect(result).toEqual({ success: false, message: 'task not found' }); 141 | }); 142 | 143 | describe('should return 400', () => { 144 | it('no updates', async () => { 145 | let response = await fetch(baseUrl, { 146 | method: 'put', 147 | body: JSON.stringify({ 148 | name: 'Task 1', 149 | description: 'Task 1 description', 150 | }), 151 | headers: { 'Content-Type': 'application/json' }, 152 | }); 153 | 154 | expect(response.status).toEqual(201); 155 | 156 | const result = await response.json(); 157 | 158 | expect(result.success).toEqual(true); 159 | expect(result.task).not.toBeNull(); 160 | expect(result.task.id).not.toBeNull(); 161 | 162 | response = await fetch(`${baseUrl}/${result.task.id}`, { 163 | method: 'post', 164 | body: JSON.stringify({}), 165 | headers: { 'Content-Type': 'application/json' }, 166 | }); 167 | 168 | expect(response.status).toEqual(400); 169 | 170 | const result2 = await response.json(); 171 | 172 | expect(result2).toEqual({ 173 | success: false, 174 | message: 'at least one update required', 175 | }); 176 | }); 177 | 178 | it('invalid status', async () => { 179 | let response = await fetch(baseUrl, { 180 | method: 'put', 181 | body: JSON.stringify({ 182 | name: 'Task 1', 183 | description: 'Task 1 description', 184 | }), 185 | headers: { 'Content-Type': 'application/json' }, 186 | }); 187 | 188 | expect(response.status).toEqual(201); 189 | 190 | const result = await response.json(); 191 | 192 | expect(result.task).not.toBeNull(); 193 | expect(result.success).toEqual(true); 194 | expect(result.task.id).not.toBeNull(); 195 | 196 | response = await fetch(`${baseUrl}/${result.task.id}`, { 197 | method: 'post', 198 | body: JSON.stringify({ status: 'invalid' }), 199 | headers: { 'Content-Type': 'application/json' }, 200 | }); 201 | 202 | expect(response.status).toEqual(400); 203 | 204 | const result2 = await response.json(); 205 | 206 | expect(result2).toEqual({ 207 | success: false, 208 | message: '"status" must be one of [new, active, completed, cancelled]', 209 | }); 210 | }); 211 | }); 212 | }); 213 | 214 | describe('should create & update a task', () => { 215 | const data = [ 216 | { 217 | name: 'only status update', 218 | taskName: 'Task 1', 219 | description: 'Task 1 description', 220 | newStatus: 'active', 221 | }, 222 | { 223 | name: 'english full update', 224 | taskName: 'Task 1', 225 | description: 'Task 1 description', 226 | newTaskName: 'Task 1 New', 227 | newDescription: 'Task 1 New description', 228 | newStatus: 'active', 229 | }, 230 | { 231 | name: 'english only name update', 232 | taskName: 'Task 1', 233 | description: 'Task 1 description', 234 | newTaskName: 'Task 1 New', 235 | }, 236 | { 237 | name: 'english only description update', 238 | taskName: 'Task 1', 239 | description: 'Task 1 description', 240 | newDescription: 'Task 1 New description', 241 | }, 242 | { 243 | name: 'japanese full update', 244 | taskName: 'タスク 1', 245 | description: 'タスク 1 説明', 246 | newTaskName: 'タスク 1 新', 247 | newDescription: 'タスク 1 新 説明', 248 | newStatus: 'active', 249 | }, 250 | { 251 | name: 'japanese only name update', 252 | taskName: 'タスク 1', 253 | description: 'タスク 1 説明', 254 | newTaskName: 'タスク 1 新', 255 | }, 256 | { 257 | name: 'japanese only description update', 258 | taskName: 'タスク 1', 259 | description: 'タスク 1 説明', 260 | newDescription: 'タスク 1 新 説明', 261 | }, 262 | { 263 | name: 'japanese only status update', 264 | taskName: 'タスク 1', 265 | description: 'タスク 1 説明', 266 | newStatus: 'active', 267 | }, 268 | { 269 | name: 'chinese full update', 270 | taskName: '任务 1', 271 | description: '任务 1 描述', 272 | newTaskName: '任务 1 新', 273 | newDescription: '任务 1 新 描述', 274 | newStatus: 'active', 275 | }, 276 | { 277 | name: 'chinese only name update', 278 | taskName: '任务 1', 279 | description: '任务 1 描述', 280 | newTaskName: '任务 1 新', 281 | }, 282 | { 283 | name: 'chinese only description update', 284 | taskName: '任务 1', 285 | description: '任务 1 描述', 286 | newDescription: '任务 1 新 描述', 287 | }, 288 | { 289 | name: 'chinese only status update', 290 | taskName: '任务 1', 291 | description: '任务 1 描述', 292 | newStatus: 'active', 293 | }, 294 | { 295 | name: 'emoji full update', 296 | taskName: '👍', 297 | description: '👍', 298 | newTaskName: '👍 👍', 299 | newDescription: '👍 👍 👍', 300 | newStatus: 'active', 301 | }, 302 | { 303 | name: 'emoji only name update', 304 | taskName: '👍', 305 | description: '👍', 306 | newTaskName: '👍 👍', 307 | }, 308 | { 309 | name: 'emoji only description update', 310 | taskName: '👍', 311 | description: '👍', 312 | newDescription: '👍 👍', 313 | }, 314 | { 315 | name: 'emoji only status update', 316 | taskName: '👍', 317 | description: '👍', 318 | newStatus: 'active', 319 | }, 320 | ]; 321 | 322 | data.forEach(({ 323 | name, taskName, description, newTaskName, newDescription, newStatus, 324 | }) => { 325 | it(name, async () => { 326 | let response = await fetch(baseUrl, { 327 | method: 'put', 328 | body: JSON.stringify({ 329 | name: taskName, 330 | description, 331 | }), 332 | headers: { 'Content-Type': 'application/json' }, 333 | }); 334 | 335 | expect(response.status).toEqual(201); 336 | 337 | const result = await response.json(); 338 | 339 | expect(result.task).not.toBeNull(); 340 | expect(result.success).toEqual(true); 341 | expect(result.task.id).not.toBeNull(); 342 | 343 | response = await fetch(`${baseUrl}/${result.task.id}`, { 344 | method: 'post', 345 | body: JSON.stringify({ 346 | name: newTaskName, 347 | description: newDescription, 348 | status: newStatus, 349 | }), 350 | headers: { 'Content-Type': 'application/json' }, 351 | }); 352 | 353 | expect(response.status).toEqual(200); 354 | 355 | const result2 = await response.json(); 356 | 357 | expect(result2).toEqual({ 358 | success: true, 359 | task: { 360 | id: result.task.id, 361 | name: newTaskName ?? taskName, 362 | description: newDescription ?? description, 363 | status: newStatus ?? 'new', 364 | createdAt: result.task.createdAt, 365 | updatedAt: expect.any(String), 366 | }, 367 | }); 368 | 369 | expect(new Date() - new Date(result2.task.updatedAt)).toBeLessThan(1000); 370 | }); 371 | }); 372 | }); 373 | 374 | describe('correct statuses update', () => { 375 | const data = [ 376 | { 377 | name: 'new-active', 378 | updates: ['active'], 379 | }, 380 | { 381 | name: 'new-cancelled', 382 | updates: ['cancelled'], 383 | }, 384 | { 385 | name: 'new-active-completed', 386 | updates: ['active', 'completed'], 387 | }, 388 | { 389 | name: 'new-active-cancelled', 390 | updates: ['active', 'cancelled'], 391 | }, 392 | ]; 393 | 394 | data.forEach(({ name, updates }) => { 395 | it(name, async () => { 396 | let response = await fetch(baseUrl, { 397 | method: 'put', 398 | body: JSON.stringify({ 399 | name: faker.lorem.word(), 400 | description: faker.lorem.sentence(), 401 | }), 402 | headers: { 'Content-Type': 'application/json' }, 403 | }); 404 | 405 | expect(response.status).toEqual(201); 406 | 407 | const result = await response.json(); 408 | 409 | expect(result.task).not.toBeNull(); 410 | expect(result.success).toEqual(true); 411 | expect(result.task.id).not.toBeNull(); 412 | 413 | for (let i = 0; i < updates.length; i += 1) { 414 | const update = updates[i]; 415 | 416 | // eslint-disable-next-line no-await-in-loop 417 | response = await fetch(`${baseUrl}/${result.task.id}`, { 418 | method: 'post', 419 | body: JSON.stringify({ 420 | status: update, 421 | }), 422 | headers: { 'Content-Type': 'application/json' }, 423 | }); 424 | 425 | expect(response.status).toEqual(200); 426 | 427 | // eslint-disable-next-line no-await-in-loop 428 | const result2 = await response.json(); 429 | 430 | expect(result2).toEqual({ 431 | success: true, 432 | task: { 433 | id: result.task.id, 434 | name: result.task.name, 435 | description: result.task.description, 436 | status: update, 437 | createdAt: result.task.createdAt, 438 | updatedAt: expect.any(String), 439 | }, 440 | }); 441 | 442 | expect(new Date() - new Date(result2.task.updatedAt)).toBeLessThan(1000); 443 | } 444 | }); 445 | }); 446 | }); 447 | 448 | describe('wrong statuses update', () => { 449 | const data = [ 450 | { 451 | name: 'new-completed', 452 | updates: ['completed'], 453 | }, 454 | { 455 | name: 'new-new', 456 | updates: ['new'], 457 | }, 458 | { 459 | name: 'new-active-new', 460 | updates: ['active', 'new'], 461 | }, 462 | { 463 | name: 'new-active-active', 464 | updates: ['active', 'active'], 465 | }, 466 | { 467 | name: 'new-active-completed-new', 468 | updates: ['active', 'completed', 'new'], 469 | }, 470 | { 471 | name: 'new-active-cancelled-active', 472 | updates: ['active', 'cancelled', 'active'], 473 | }, 474 | { 475 | name: 'new-active-completed-cancelled', 476 | updates: ['active', 'completed', 'cancelled'], 477 | }, 478 | ]; 479 | 480 | data.forEach(({ name, updates }) => { 481 | it(name, async () => { 482 | let response = await fetch(baseUrl, { 483 | method: 'put', 484 | body: JSON.stringify({ 485 | name: faker.lorem.word(), 486 | description: faker.lorem.sentence(), 487 | }), 488 | headers: { 'Content-Type': 'application/json' }, 489 | }); 490 | 491 | expect(response.status).toEqual(201); 492 | 493 | const result = await response.json(); 494 | 495 | expect(result.task).not.toBeNull(); 496 | expect(result.success).toEqual(true); 497 | expect(result.task.id).not.toBeNull(); 498 | 499 | for (let i = 0; i < updates.length - 1; i += 1) { 500 | const update = updates[i]; 501 | 502 | // eslint-disable-next-line no-await-in-loop 503 | response = await fetch(`${baseUrl}/${result.task.id}`, { 504 | method: 'post', 505 | body: JSON.stringify({ 506 | status: update, 507 | }), 508 | headers: { 'Content-Type': 'application/json' }, 509 | }); 510 | 511 | expect(response.status).toEqual(200); 512 | 513 | // eslint-disable-next-line no-await-in-loop 514 | const result2 = await response.json(); 515 | 516 | expect(result2.success).toEqual(true); 517 | } 518 | 519 | const update = updates[updates.length - 1]; 520 | const prevStatus = updates.length - 2 >= 0 ? updates[updates.length - 2] : 'new'; 521 | 522 | response = await fetch(`${baseUrl}/${result.task.id}`, { 523 | method: 'post', 524 | body: JSON.stringify({ 525 | status: update, 526 | }), 527 | headers: { 'Content-Type': 'application/json' }, 528 | }); 529 | 530 | expect(response.status).toEqual(400); 531 | 532 | const result3 = await response.json(); 533 | 534 | expect(result3).toEqual({ 535 | success: false, 536 | message: `cannot update from '${prevStatus}' to '${update}'`, 537 | }); 538 | }); 539 | }); 540 | }); 541 | 542 | describe('concurrent updates', () => { 543 | it('one should 200, another 400', async () => { 544 | for (let i = 0; i < 20; i += 1) { 545 | // eslint-disable-next-line no-await-in-loop 546 | let response = await fetch(baseUrl, { 547 | method: 'put', 548 | body: JSON.stringify({ 549 | name: faker.lorem.word(), 550 | description: faker.lorem.sentence(), 551 | }), 552 | headers: { 'Content-Type': 'application/json' }, 553 | }); 554 | 555 | expect(response.status).toEqual(201); 556 | 557 | // eslint-disable-next-line no-await-in-loop 558 | const result = await response.json(); 559 | 560 | expect(result.task).not.toBeNull(); 561 | expect(result.success).toEqual(true); 562 | expect(result.task.id).not.toBeNull(); 563 | 564 | // eslint-disable-next-line no-await-in-loop 565 | response = await fetch(`${baseUrl}/${result.task.id}`, { 566 | method: 'post', 567 | body: JSON.stringify({ 568 | status: 'active', 569 | }), 570 | headers: { 'Content-Type': 'application/json' }, 571 | }); 572 | 573 | expect(response.status).toEqual(200); 574 | 575 | const promise1 = fetch(`${baseUrl}/${result.task.id}`, { 576 | method: 'post', 577 | body: JSON.stringify({ 578 | status: 'completed', 579 | }), 580 | headers: { 'Content-Type': 'application/json' }, 581 | }); 582 | 583 | const promise2 = fetch(`${baseUrl}/${result.task.id}`, { 584 | method: 'post', 585 | body: JSON.stringify({ 586 | status: 'cancelled', 587 | }), 588 | headers: { 'Content-Type': 'application/json' }, 589 | }); 590 | 591 | // eslint-disable-next-line no-await-in-loop 592 | const [response1, response2] = await Promise.all([promise1, promise2]); 593 | 594 | if (response1.status === 200) { 595 | expect(response1.status).toEqual(200); 596 | expect(response2.status).toEqual(400); 597 | } else { 598 | expect(response2.status).toEqual(200); 599 | expect(response1.status).toEqual(400); 600 | } 601 | } 602 | }); 603 | }); 604 | }); 605 | }); 606 | -------------------------------------------------------------------------------- /tests/unit/services/task.test.js: -------------------------------------------------------------------------------- 1 | const { faker } = require('@faker-js/faker'); 2 | const Task = require('../../../src/models/task'); 3 | const taskService = require('../../../src/services/task'); 4 | 5 | afterEach(() => { 6 | jest.restoreAllMocks(); 7 | }); 8 | 9 | describe('TaskService', () => { 10 | describe('getTaskById', () => { 11 | it('should return a task', async () => { 12 | const objId = faker.string.uuid(); 13 | const name = faker.string.uuid(); 14 | const description = faker.lorem.sentence(); 15 | const task = { id: objId, name, description }; 16 | jest.spyOn(Task, 'findById').mockImplementation((id) => (objId === id ? task : undefined)); 17 | const result = await taskService.getTaskById(objId); 18 | expect(result.id).toEqual(objId); 19 | expect(result.name).toEqual(name); 20 | expect(result.description).toEqual(description); 21 | expect(Task.findById).toBeCalledWith(objId); 22 | }); 23 | }); 24 | 25 | describe('createTask', () => { 26 | it('should create a task', async () => { 27 | const name = faker.string.uuid(); 28 | const description = faker.lorem.sentence(); 29 | jest.spyOn(Task, 'create').mockResolvedValue({ name, description }); 30 | const result = await taskService.createTask(name, description); 31 | expect(result.name).toEqual(name); 32 | expect(result.description).toEqual(description); 33 | expect(Task.create).toBeCalledWith({ name, description }); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/unit/utils/pick.test.js: -------------------------------------------------------------------------------- 1 | const pick = require('../../../src/utils/pick'); 2 | 3 | describe('pick', () => { 4 | const data = [ 5 | { 6 | name: 'should return an empty object if no keys are provided', 7 | object: { a: 1, b: 2, c: 3 }, 8 | keys: [], 9 | expected: {}, 10 | }, 11 | { 12 | name: 'should return an empty object if object is null', 13 | object: null, 14 | keys: ['a', 'b', 'c'], 15 | expected: {}, 16 | }, 17 | { 18 | name: 'should return an empty object if object is undefined', 19 | object: undefined, 20 | keys: ['a', 'b', 'c'], 21 | expected: {}, 22 | }, 23 | { 24 | name: 'should return an empty object if object is empty', 25 | object: {}, 26 | keys: ['a', 'b', 'c'], 27 | expected: {}, 28 | }, 29 | { 30 | name: 'should return an empty object if keys is empty', 31 | object: { a: 1, b: 2, c: 3 }, 32 | keys: [], 33 | expected: {}, 34 | }, 35 | { 36 | name: 'should return an empty object if keys is not an array of strings', 37 | object: { a: 1, b: 2, c: 3 }, 38 | keys: [1, 2, 3], 39 | expected: {}, 40 | }, 41 | { 42 | name: 'should return an empty object if keys is not an array of strings', 43 | object: { a: 1, b: 2, c: 3 }, 44 | keys: [true, false, true], 45 | expected: {}, 46 | }, 47 | { 48 | name: 'should return an empty object if keys is not an array of strings', 49 | object: { a: 1, b: 2, c: 3 }, 50 | keys: [null, undefined, null], 51 | expected: {}, 52 | }, 53 | { 54 | name: 'should return an empty object if keys is not an array of strings', 55 | object: { a: 1, b: 2, c: 3 }, 56 | keys: [{}, {}, {}], 57 | expected: {}, 58 | }, 59 | { 60 | name: 'should return picked object', 61 | object: { a: 1, b: 2, c: 3 }, 62 | keys: ['a', 'b'], 63 | expected: { a: 1, b: 2 }, 64 | }, 65 | ]; 66 | 67 | data.forEach(({ 68 | name, object, keys, expected, 69 | }) => { 70 | it(name, () => { 71 | const result = pick(object, keys); 72 | expect(result).toEqual(expected); 73 | }); 74 | }); 75 | }); 76 | --------------------------------------------------------------------------------