├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .huskyrc.json ├── .lintstagedrc.json ├── .prettierrc ├── .travis.yml ├── .vscode └── launch.json ├── LICENSE.md ├── README.md ├── babel.config.js ├── docker-compose.yml ├── globalConfig.json ├── jest-integration-config.js ├── jest-mongodb-config.js ├── jest-unit-config.js ├── jest.config.js ├── package.json ├── public ├── nodejs-logo.png └── typescript-logo.png ├── src ├── @types │ └── express-request.d.ts ├── data │ ├── interfaces │ │ ├── cryptography │ │ │ ├── decrypter.ts │ │ │ ├── encrypter.ts │ │ │ ├── hash-comparer.ts │ │ │ └── hasher.ts │ │ └── db │ │ │ ├── account │ │ │ ├── access-token-repository.ts │ │ │ ├── add-account-repository.ts │ │ │ ├── load-account-by-token-repository.ts │ │ │ └── load-account-repository.ts │ │ │ ├── log │ │ │ └── log-error-repository.ts │ │ │ └── survey │ │ │ ├── add-survey-repository.ts │ │ │ ├── load-survey-by-id-repository.ts │ │ │ ├── load-survey-repository.ts │ │ │ ├── load-survey-results-repository.ts │ │ │ └── save-survey-result-repository.ts │ └── usecases │ │ ├── add-account │ │ ├── db-add-account.spec.ts │ │ └── db-add-account.ts │ │ ├── add-survey │ │ ├── db-add-survey.spec.ts │ │ └── db-add-survey.ts │ │ ├── authentication │ │ ├── db-authentication.spec.ts │ │ └── db-authentication.ts │ │ ├── load-account │ │ ├── db-load-account-by-token.spec.ts │ │ └── db-load-account-by-token.ts │ │ ├── load-survey-by-id │ │ ├── load-survey-by-id.spec.ts │ │ └── load-survey-by-id.ts │ │ ├── load-survey-result │ │ ├── db-load-survey-result.spec.ts │ │ └── db-load-survey-result.ts │ │ ├── load-survey │ │ ├── db-load-survey.spec.ts │ │ └── db-load-survey.ts │ │ └── save-survey-result │ │ ├── db-save-survey-result.spec.ts │ │ └── db-save-survey-result.ts ├── domain │ ├── mocks │ │ ├── mock-account.ts │ │ ├── mock-survey-result.ts │ │ └── mock-survey.ts │ ├── models │ │ ├── account.ts │ │ ├── survey-results.ts │ │ └── survey.ts │ └── usecases │ │ ├── add-account.ts │ │ ├── add-survey.ts │ │ ├── authentication.ts │ │ ├── load-account-by-token.ts │ │ ├── load-survey-results.ts │ │ ├── load-survey.ts │ │ └── save-survey-results.ts ├── infra │ ├── cryptography │ │ ├── bcrypt-adapter │ │ │ ├── bcrypt-adapter.spec.ts │ │ │ └── bcrypt-adapter.ts │ │ └── jwt-adapter │ │ │ ├── jwt-adapter.spec.ts │ │ │ └── jwt-adapter.ts │ ├── db │ │ └── mongodb │ │ │ ├── account │ │ │ ├── mongo-account-repository.spec.ts │ │ │ └── mongo-account-repository.ts │ │ │ ├── helpers │ │ │ ├── index.ts │ │ │ ├── mongo.spec.ts │ │ │ ├── mongo.ts │ │ │ └── query-bulder.ts │ │ │ ├── log │ │ │ ├── mongo-log-repository.spec.ts │ │ │ └── mongo-log-repository.ts │ │ │ ├── survey-result │ │ │ ├── mongo-survey-results-repository.spec.ts │ │ │ └── mongo-survey-results-repository.ts │ │ │ └── survey │ │ │ ├── mongo-survey-repository.spec.ts │ │ │ └── mongo-survey-repository.ts │ └── validation │ │ ├── email-validator-adapter.spec.ts │ │ └── email-validator-adapter.ts ├── main │ ├── adapters │ │ ├── apollo-server-resolver-adapter.ts │ │ ├── express-middleware.ts │ │ └── express-routes.ts │ ├── config │ │ ├── apollo-server.ts │ │ ├── app.ts │ │ ├── env.ts │ │ ├── except-route.ts │ │ ├── middlewares.ts │ │ ├── routes.ts │ │ ├── static-files.ts │ │ └── swagger.ts │ ├── decorators │ │ ├── log-controller-decorator.spec.ts │ │ └── log-controller-decorator.ts │ ├── docs │ │ ├── components │ │ │ ├── bad-request.ts │ │ │ ├── forbidden.ts │ │ │ ├── not-found.ts │ │ │ ├── server-error.ts │ │ │ └── unauthorized.ts │ │ ├── index.ts │ │ ├── paths │ │ │ ├── login-path.ts │ │ │ ├── signup-path.ts │ │ │ ├── survey-result-path.ts │ │ │ └── surveys-path.ts │ │ └── schemas │ │ │ ├── account-schema.ts │ │ │ ├── add-survey-schema.ts │ │ │ ├── api-key-auth-schema.ts │ │ │ ├── error-schema.ts │ │ │ ├── login-schema.ts │ │ │ ├── save-survey-schema.ts │ │ │ ├── signup-schema.ts │ │ │ ├── survey-answer-schema.ts │ │ │ ├── survey-result-answer-schema.ts │ │ │ ├── survey-result-schema.ts │ │ │ ├── survey-schema.ts │ │ │ └── surveys-schema.ts │ ├── factories │ │ ├── add-survey │ │ │ ├── add-survey-controller-factory.ts │ │ │ ├── add-survey-validations-factory.spec.ts │ │ │ └── add-survey-validations-factory.ts │ │ ├── decorators │ │ │ └── log-controller-factory.ts │ │ ├── load-survey-result │ │ │ └── load-survey-result-controller.ts │ │ ├── load-survey │ │ │ └── load-survey-controller.ts │ │ ├── login │ │ │ ├── login-factory.ts │ │ │ ├── login-validation-factory.spec.ts │ │ │ └── login-validation-factory.ts │ │ ├── middlewares │ │ │ └── auth-middleware.ts │ │ ├── save-survey-result │ │ │ └── save-survey-result-controller.ts │ │ ├── signup │ │ │ ├── signup-factory.ts │ │ │ ├── signup-validation-factory.spec.ts │ │ │ └── signup-validation-factory.ts │ │ └── usecases │ │ │ ├── db-add-account-factory.ts │ │ │ ├── db-add-survey-factory.ts │ │ │ ├── db-authentication-factory.ts │ │ │ ├── db-load-account-by-token.ts │ │ │ ├── db-load-survey-by-id.ts │ │ │ ├── db-load-survey-result.ts │ │ │ ├── db-load-survey.ts │ │ │ └── db-save-survey-result-factory.ts │ ├── graphql │ │ ├── __tests__ │ │ │ ├── helpers.ts │ │ │ └── login.test.ts │ │ ├── directives │ │ │ ├── auth-directive.ts │ │ │ └── index.ts │ │ ├── resolvers │ │ │ ├── base.ts │ │ │ ├── index.ts │ │ │ ├── login.ts │ │ │ ├── survey-result.ts │ │ │ └── survey.ts │ │ └── type-defs │ │ │ ├── base.ts │ │ │ ├── index.ts │ │ │ ├── login.ts │ │ │ ├── survey-result.ts │ │ │ └── survey.ts │ ├── middlewares │ │ ├── adm-auth.ts │ │ ├── auth.ts │ │ ├── body-parser.spec.ts │ │ ├── body-parser.ts │ │ ├── content-type.spec.ts │ │ ├── content-type.ts │ │ ├── cors.test.ts │ │ ├── cors.ts │ │ ├── index.ts │ │ ├── no-cache.test.ts │ │ └── no-cache.ts │ ├── routes │ │ ├── login-routes.test.ts │ │ ├── logn-routes.ts │ │ ├── signup-routes.test.ts │ │ ├── signup-routes.ts │ │ ├── survey-result-routes.test.ts │ │ ├── survey-results-routes.ts │ │ ├── survey-routes.test.ts │ │ └── survey-routes.ts │ └── server.ts ├── presentation │ ├── controllers │ │ ├── login │ │ │ ├── login-controller.spec.ts │ │ │ └── login-controller.ts │ │ ├── signup │ │ │ ├── signup-controller.spec.ts │ │ │ └── signup-controller.ts │ │ ├── survey-result │ │ │ ├── load-survey-result-controller.spec.ts │ │ │ ├── load-survey-result-controller.ts │ │ │ ├── save-survey-result-controller.spec.ts │ │ │ └── save-survey-result-controller.ts │ │ └── survey │ │ │ ├── add-survey │ │ │ ├── add-survey.spec.ts │ │ │ └── add-survey.ts │ │ │ └── load-survey │ │ │ ├── load-survey-controller.spec.ts │ │ │ └── load-survey-controller.ts │ ├── errors │ │ ├── access-denied.ts │ │ ├── email-already-in-use.ts │ │ ├── index.ts │ │ ├── invalid-param.ts │ │ ├── missing-param.ts │ │ ├── server.ts │ │ └── unauthorized.ts │ ├── helpers │ │ └── http │ │ │ └── http.ts │ ├── interfaces │ │ ├── controller.ts │ │ ├── http.ts │ │ ├── index.ts │ │ ├── middleware.ts │ │ └── validation.ts │ └── middlewares │ │ ├── auth-middleware.spec.ts │ │ └── auth-middleware.ts └── validation │ ├── interfaces │ └── email-validator.ts │ └── validator │ ├── compare-fields-validation.ts │ ├── email-validation.ts │ ├── required-fields-validation.ts │ ├── tests │ ├── compare-fields-validation.spec.ts │ ├── email-validation.spec.ts │ ├── required-field-validation.spec.ts │ └── validation-composite.spec.ts │ └── validation-composite.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = crlf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = false -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT = 5050 2 | MONGO_URL = "mongodb://localhost:27017/clean-typescript-api" 3 | JWT_SECRET_KEY = "c1f1b5c0a90e9177daaf2f26f6b0a1af" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | @types/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": ["standard"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": 12, 11 | // "tsconfigRootDir": ".", 12 | // "project": "tsconfig.json", 13 | "sourceType": "module" 14 | }, 15 | "plugins": ["@typescript-eslint"], 16 | "rules": { 17 | "space-before-function-paren": [ 18 | "error", 19 | { 20 | "anonymous": "always", 21 | "named": "never", 22 | "asyncArrow": "always" 23 | } 24 | ], 25 | "no-useless-constructor": "off", 26 | "import/export": "off", 27 | "no-redeclare": "off" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Docker generated folders 2 | ./data 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | .parcel-cache 81 | 82 | # Next.js build output 83 | .next 84 | out 85 | 86 | # Nuxt.js build / generate output 87 | .nuxt 88 | dist 89 | 90 | # Gatsby files 91 | .cache/ 92 | # Comment in the public line in if your project uses Gatsby and not Next.js 93 | # https://nextjs.org/blog/next-9-1#public-directory-support 94 | # public 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # TernJS port file 109 | .tern-port 110 | 111 | # Stores VSCode versions used for testing VSCode extensions 112 | .vscode-test 113 | 114 | # yarn v2 115 | .yarn/cache 116 | .yarn/unplugged 117 | .yarn/build-state.yml 118 | .yarn/install-state.gz 119 | .pnp.* -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "pre-push": "yarn test:ci" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.ts": [ 3 | "yarn lint", 4 | "yarn test" 5 | ] 6 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "printWidth": 100, 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid" 9 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14 4 | scripts: 5 | - eslint 'src/**' 6 | - npm run test:coveralls 7 | # deploy: 8 | # provider: heroku 9 | # api_key: $HEROKU_KEY 10 | # on: 11 | # all_branches: true 12 | # app: clean-typescript-api 13 | # skip_cleanup: true 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Docker: Attach to Node", 8 | "remoteRoot": "/usr/src/clean-typescript-api", 9 | "port": 9222, 10 | "restart": true 11 | }, 12 | { 13 | "type": "node", 14 | "request": "attach", 15 | "name": "Attach to Node", 16 | "port": 9222, 17 | "restart": true 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Gabriel Lopes 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 |

2 | Clean Typescript API 3 | typescript 4 |

5 | 6 | ![version badge](https://img.shields.io/badge/version-2.0.0-blue.svg) 7 | [![Build Status](https://travis-ci.org/gabriellopes00/clean-typescript-api.svg?branch=main)](https://travis-ci.org/gabriellopes00/clean-typescript-api) 8 | [![Coverage Status](https://coveralls.io/repos/github/gabriellopes00/clean-typescript-api/badge.svg)](https://coveralls.io/github/gabriellopes00/clean-typescript-api) 9 | ![stars badge](https://img.shields.io/github/stars/gabriellopes00/clean-typescript-api.svg) 10 | ![license badge](https://img.shields.io/badge/license-MIT-blue.svg) 11 | 12 | ##### Application hosted at _[heroku](https://www.heroku.com/)_. 13 | 14 | ##### API url: _https://clean-typescript-api.herokuapp.com/_. See the [documentation](https://clean-typescript-api.herokuapp.com/api/docs). 15 | 16 | ###### An API mande with 17 | 18 |

19 | typescript 20 | mongodb 21 | nodejs 22 | docker 23 | eslint 24 | jest 25 | heroku 26 | travis-ci 27 | swagger 28 |

29 | 30 | ## About this project ⚙ 31 | 32 | This is an API made in [NodeJs, Typescript, TDD, Clean Architecture e SOLID Course](https://www.udemy.com/course/tdd-com-mango/), course led by [Rodrigo Mango](https://github.com/rmanguinho). In this course we develop an API using Node.JS, Typescript, Mongodb and all good programming practices, such as Clean Architecture, SOLID principles, TDD and Design Patterns. 🖋 Although i did this project following the course, i made my own changes and improvements. 33 | 34 | ## Building 🔧 35 | 36 | You'll need [Node.js](https://nodejs.org), [Mongodb](https://www.mongodb.com/) and i recommend that you have installed the [Yarn](https://yarnpkg.com/getting-started/install) on your computer. After, you can run the scripts below... 37 | 38 | ###### Cloning Repository 39 | 40 | ```cloning 41 | git clone https://github.com/gabriellopes00/clean-typescript-api.git && 42 | cd clean-typescript-api && 43 | yarn install || npm install 44 | ``` 45 | 46 | ###### Running API (development environment) 47 | 48 | ```development 49 | yarn dev || npm run dev 50 | ``` 51 | 52 | ###### Generating Build and running build 53 | 54 | ```build 55 | yarn build && yarn start || npm run build && npm run start 56 | ``` 57 | 58 | ###### Docker 🐳 59 | 60 | You will need to have [docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) installed on your computer to run the commands below. Running this commands the containers will be pulled [node:14](https://hub.docker.com/_/node) image and [mongo:4](https://hub.docker.com/_/mongo) image, and the containers will be created on top of this images. 61 | 62 | ```upping 63 | yarn up || npm run up 64 | ``` 65 | 66 | ```downing 67 | yarn down || npm run down 68 | ``` 69 | 70 | ###### Tests (jest) 🧪 71 | 72 | - _**All**_ ❯ `yarn test` 73 | - _**Coverage**_ ❯ `yarn test:ci` 74 | - _**Watch**_ ❯ `yarn test:watch` 75 | - _**Unit**(.spec)_ ❯ `yarn test:unit` 76 | - _**Integration**(.test)_ ❯ `yarn test:integration` 77 | - _**Staged**_ ❯ `yarn test:staged` 78 | - _**Verbose**(view logs)_ ❯ `yarn test:verbose` 79 | 80 | ###### Lint (eslint) 🎭 81 | 82 | - _**Lint**(fix)_ ❯ `yarn lint` 83 | 84 | ###### Debug 🐞 85 | 86 | - _**Debug**_ ❯ `yarn debug` 87 | 88 | ###### Statistics of the types of commits 📊📈 89 | 90 | Following the standard of the [Conventional Commits](https://www.conventionalcommits.org/). 91 | 92 | - _**feature** commits(amount)_ ❯ `git shortlog -s --grep feat` 93 | - _**test** commits(amount)_ ❯ `git shortlog -s --grep test` 94 | - _**refactor** commits(amount)_ ❯ `git shortlog -s --grep refactor` 95 | - _**chore** commits(amount)_ ❯ `git shortlog -s --grep chore` 96 | - _**docs** commits(amount)_ ❯ `git shortlog -s --grep docs` 97 | - _**build** commits(amount)_ ❯ `git shortlog -s --grep build` 98 | 99 | ## Contact 📱 100 | 101 | [![Github Badge](https://img.shields.io/badge/-Github-000?style=flat-square&logo=Github&logoColor=white&link=https://github.com/gabriellopes00)](https://github.com/gabriellopes00) 102 | [![Linkedin Badge](https://img.shields.io/badge/-LinkedIn-blue?style=flat-square&logo=Linkedin&logoColor=white&link=https://www.linkedin.com/in/gabriel-lopes-6625631b0/)](https://www.linkedin.com/in/gabriel-lopes-6625631b0/) 103 | [![Twitter Badge](https://img.shields.io/badge/-Twitter-1ca0f1?style=flat-square&labelColor=1ca0f1&logo=twitter&logoColor=white&link=https://twitter.com/_gabrielllopes_)](https://twitter.com/_gabrielllopes_) 104 | [![Gmail Badge](https://img.shields.io/badge/-Gmail-D14836?&style=flat-square&logo=Gmail&logoColor=white&link=mailto:gabrielluislopes00@gmail.com)](mailto:gabrielluislopes00@gmail.com) 105 | [![Facebook Badge](https://img.shields.io/badge/facebook-%231877F2.svg?&style=flat-square&logo=facebook&logoColor=white)](https://www.facebook.com/profile.php?id=100034920821684) 106 | [![Instagram Badge](https://img.shields.io/badge/instagram-%23E4405F.svg?&style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/_.gabriellopes/?hl=pt-br) 107 | [![StackOverflow Badge](https://img.shields.io/badge/stack%20overflow-FE7A16?logo=stack-overflow&logoColor=white&style=flat-square)](https://stackoverflow.com/users/14099025/gabriel-lopes?tab=profile) 108 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current' 8 | } 9 | } 10 | ], 11 | '@babel/preset-typescript' 12 | ], 13 | plugins: [ 14 | [ 15 | 'module-resolver', 16 | { 17 | alias: { 18 | '@presentation': './src/presentation', 19 | '@main': './src/main', 20 | '@domain': './src/domain', 21 | '@data': './src/data', 22 | '@infra': './src/infra' 23 | } 24 | } 25 | ] 26 | ], 27 | ignore: ['**/*.spec.ts', '**/*.test.ts', '**/*.spec.js', '**/*.test.js'] 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | api: 4 | container_name: api_container 5 | image: node:14 6 | working_dir: /usr/src/clean-typescript-api 7 | restart: always 8 | command: bash -c "yarn install --only=prod && yarn start" 9 | environment: 10 | - PORT=5050 11 | - MONGO_URL=mongodb://mongo:27017/clean-typescript-api 12 | - JWT_SECRET_KEY="secret_key 13 | volumes: 14 | - ./dist/:/usr/src/clean-typescript-api/dist/ 15 | - ./package.json:/usr/src/clean-typescript-api/package.json 16 | ports: 17 | - '5050:5050' 18 | - '9222:9222' 19 | networks: 20 | - clean-typescript-api 21 | 22 | mongo: 23 | container_name: mongo_container 24 | image: mongo:latest 25 | restart: always 26 | volumes: 27 | - mongodb:/data/db 28 | ports: 29 | - '27017:27017' 30 | networks: 31 | - clean-typescript-api 32 | 33 | networks: 34 | clean-typescript-api: 35 | 36 | volumes: 37 | mongodb: 38 | -------------------------------------------------------------------------------- /globalConfig.json: -------------------------------------------------------------------------------- 1 | {"mongoUri":"mongodb://127.0.0.1:59894/9da61ef9-8ef8-40e9-898c-aa6ec6d5f34d?"} -------------------------------------------------------------------------------- /jest-integration-config.js: -------------------------------------------------------------------------------- 1 | const config = require('./jest.config') 2 | config.testMatch = ['**/*.test.ts'] 3 | 4 | module.exports = config 5 | -------------------------------------------------------------------------------- /jest-mongodb-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mongodbMemoryServerOptions: { 3 | binary: { 4 | version: '4.4.2', 5 | skipMD5: true 6 | }, 7 | autoStart: false, 8 | instance: {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /jest-unit-config.js: -------------------------------------------------------------------------------- 1 | const config = require('./jest.config') 2 | config.testMatch = ['**/*.spec.ts'] 3 | 4 | module.exports = config 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { compilerOptions } = require('./tsconfig.json') 2 | const { pathsToModuleNameMapper } = require('ts-jest/utils') 3 | 4 | module.exports = { 5 | roots: ['/src'], 6 | collectCoverageFrom: [ 7 | '/src/**/*.ts', 8 | '!/src/**/*-interfaces.ts', 9 | '!**/interfaces/**', 10 | '!/src/main/**', 11 | '!/src/@types/**' 12 | ], 13 | coverageDirectory: 'coverage', 14 | coverageProvider: 'babel', 15 | testEnvironment: 'node', 16 | preset: '@shelf/jest-mongodb', 17 | transform: { 18 | '.+\\.ts$': 'ts-jest' 19 | }, 20 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 21 | prefix: '' 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clean-typescript-api", 3 | "version": "2.0.0", 4 | "description": "A typescript api made with clean architecture and TDD", 5 | "main": "src/main/server.ts", 6 | "author": "Gabriel Lopes ", 7 | "license": "MIT", 8 | "scripts": { 9 | "test": "jest --passWithNoTests --silent --noStackTrace --runInBand --no-cache", 10 | "test:verbose": "jest --passWithNoTests --runInBand", 11 | "test:watch": "yarn test --watch", 12 | "test:unit": "yarn test:watch -c jest-unit-config.js", 13 | "test:integration": "yarn test:watch -c jest-integration-config.js", 14 | "test:staged": "yarn test --findRelatedTests", 15 | "test:ci": "yarn test --coverage", 16 | "test:coveralls": "yarn test:ci && coveralls < coverage/lcov.info", 17 | "lint": "eslint src/** --fix", 18 | "dev": "ts-node-dev -r tsconfig-paths/register --respawn --transpile-only --ignore-watch node_modules --no-notify src/main/server.ts", 19 | "up": "yarn build && docker-compose up -d", 20 | "down": "docker-compose down", 21 | "build": "rimraf ./dist && babel src --extensions \".js,.ts\" --out-dir dist --copy-files --no-copy-ignored", 22 | "postbuild": "yarn copyfiles -u 1 public/**/* dist/static", 23 | "start": "node ./dist/main/server.js", 24 | "debug": "node --inspect=0.0.0.0:9222 --nolazy ./dist/main/server.js", 25 | "start:live": "nodemon -L --watch ./dist ./dist/main/server.js", 26 | "debug:live": "nodemon -L --watch ./dist --inspect=0.0.0.0:9222 --nolazy ./dist/main/server.js" 27 | }, 28 | "devDependencies": { 29 | "@babel/cli": "^7.10.1", 30 | "@babel/core": "^7.10.2", 31 | "@babel/preset-env": "^7.10.2", 32 | "@babel/preset-typescript": "^7.10.1", 33 | "@shelf/jest-mongodb": "^1.2.3", 34 | "@types/bcrypt": "^3.0.0", 35 | "@types/express": "^4.17.9", 36 | "@types/graphql": "^14.5.0", 37 | "@types/graphql-iso-date": "^3.4.0", 38 | "@types/jest": "^26.0.20", 39 | "@types/jsonwebtoken": "^8.5.0", 40 | "@types/mocha": "^8.2.0", 41 | "@types/mongodb": "^3.6.3", 42 | "@types/node": "^14.14.12", 43 | "@types/supertest": "^2.0.10", 44 | "@types/swagger-ui-express": "^4.1.2", 45 | "@types/validator": "^13.1.1", 46 | "@typescript-eslint/eslint-plugin": "^4.9.1", 47 | "@typescript-eslint/parser": "^4.9.1", 48 | "apollo-server-integration-testing": "^2.3.1", 49 | "babel-eslint": "^10.1.0", 50 | "babel-plugin-module-resolver": "^4.0.0", 51 | "copyfiles": "^2.4.1", 52 | "coveralls": "^3.1.0", 53 | "eslint": "^7.12.1", 54 | "eslint-config-standard": "^16.0.2", 55 | "eslint-plugin-import": "^2.22.1", 56 | "eslint-plugin-jest": "^24.1.3", 57 | "eslint-plugin-node": "^11.1.0", 58 | "eslint-plugin-promise": "^4.2.1", 59 | "git-commit-msg-linter": "^3.0.0", 60 | "husky": "^4.3.5", 61 | "jest": "^26.6.3", 62 | "lint-staged": "^10.5.3", 63 | "mockdate": "^3.0.2", 64 | "rimraf": "^3.0.2", 65 | "supertest": "^6.0.1", 66 | "ts-jest": "^26.4.4", 67 | "ts-node-dev": "^1.1.1", 68 | "tsconfig-paths": "^3.9.0", 69 | "typescript": "^4.1.2" 70 | }, 71 | "dependencies": { 72 | "apollo-server-express": "^2.22.2", 73 | "bcrypt": "^5.0.0", 74 | "dotenv": "^8.2.0", 75 | "express": "^4.17.1", 76 | "graphql": "^15.5.0", 77 | "graphql-iso-date": "^3.6.1", 78 | "jsonwebtoken": "^8.5.1", 79 | "mongodb": "^3.6.3", 80 | "nodemon": "^2.0.7", 81 | "swagger-ui-express": "^4.1.6", 82 | "validator": "^13.5.2" 83 | }, 84 | "engines": { 85 | "node": "14" 86 | } 87 | } -------------------------------------------------------------------------------- /public/nodejs-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabriellopes00/clean-typescript-api/4190ae2423e11e5b99880c29d4f384c70b329a78/public/nodejs-logo.png -------------------------------------------------------------------------------- /public/typescript-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabriellopes00/clean-typescript-api/4190ae2423e11e5b99880c29d4f384c70b329a78/public/typescript-logo.png -------------------------------------------------------------------------------- /src/@types/express-request.d.ts: -------------------------------------------------------------------------------- 1 | declare module Express { 2 | interface Request { 3 | accountId?: string 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/data/interfaces/cryptography/decrypter.ts: -------------------------------------------------------------------------------- 1 | export interface Decrypter { 2 | decript(value: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/data/interfaces/cryptography/encrypter.ts: -------------------------------------------------------------------------------- 1 | export interface Encrypter { 2 | encrypt(value: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/data/interfaces/cryptography/hash-comparer.ts: -------------------------------------------------------------------------------- 1 | export interface HashComparer { 2 | compare(value: string, hash: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/data/interfaces/cryptography/hasher.ts: -------------------------------------------------------------------------------- 1 | export interface Hasher { 2 | hash(value: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/data/interfaces/db/account/access-token-repository.ts: -------------------------------------------------------------------------------- 1 | export interface AccessTokenRepository { 2 | storeAccessToken(id: string, token: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/data/interfaces/db/account/add-account-repository.ts: -------------------------------------------------------------------------------- 1 | import { AddAccountParams } from '@domain/usecases/add-account' 2 | import { AccountModel } from '@domain/models/account' 3 | 4 | export interface AddAccountRepository { 5 | add(accountData: AddAccountParams): Promise 6 | } 7 | -------------------------------------------------------------------------------- /src/data/interfaces/db/account/load-account-by-token-repository.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@domain/models/account' 2 | 3 | export interface LoadAccountByTokenRepository { 4 | loadByToken(token: string, role?: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/data/interfaces/db/account/load-account-repository.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@domain/models/account' 2 | 3 | export interface LoadAccountRepository { 4 | loadByEmail(email: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/data/interfaces/db/log/log-error-repository.ts: -------------------------------------------------------------------------------- 1 | export interface LogErrorRepository { 2 | logError(stackError: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/data/interfaces/db/survey/add-survey-repository.ts: -------------------------------------------------------------------------------- 1 | import { AddSurveyParams } from '@domain/usecases/add-survey' 2 | 3 | export interface AddSurveyRepository { 4 | add(accountData: AddSurveyParams): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/data/interfaces/db/survey/load-survey-by-id-repository.ts: -------------------------------------------------------------------------------- 1 | import { SurveyModel } from '@domain/models/survey' 2 | 3 | export interface LoadSurveyByIdRepository { 4 | loadById(id: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/data/interfaces/db/survey/load-survey-repository.ts: -------------------------------------------------------------------------------- 1 | import { SurveyModel } from '@domain/models/survey' 2 | 3 | export interface LoadSurveyRepository { 4 | loadAll(accountId: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/data/interfaces/db/survey/load-survey-results-repository.ts: -------------------------------------------------------------------------------- 1 | import { SurveyResultsModel } from '@domain/models/survey-results' 2 | 3 | export interface LoadSurveyResultRepository { 4 | loadBySurveyId(surveyId: string, accountId: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/data/interfaces/db/survey/save-survey-result-repository.ts: -------------------------------------------------------------------------------- 1 | import { SaveSurveyResultParams } from '@domain/usecases/save-survey-results' 2 | 3 | export interface SaveSurveyResultsRepository { 4 | save(data: SaveSurveyResultParams): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/data/usecases/add-account/db-add-account.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAccountModel, fakeAccountParams } from '../../../domain/mocks/mock-account' 2 | import { AccountModel } from '../../../domain/models/account' 3 | import { AddAccountParams } from '../../../domain/usecases/add-account' 4 | import { Hasher } from '../../interfaces/cryptography/hasher' 5 | import { AddAccountRepository } from '../../interfaces/db/account/add-account-repository' 6 | import { LoadAccountRepository } from '../../interfaces/db/account/load-account-repository' 7 | import { DbAddAccount } from './db-add-account' 8 | 9 | class MockHasher implements Hasher { 10 | async hash(value: string): Promise { 11 | return new Promise(resolve => resolve('hashed_password')) 12 | } 13 | } 14 | class MockAddAccountRepository implements AddAccountRepository { 15 | async add(values: AddAccountParams): Promise { 16 | return new Promise(resolve => resolve(fakeAccountModel)) 17 | } 18 | } 19 | 20 | class MockLoadAccountRepository implements LoadAccountRepository { 21 | async loadByEmail(email: string): Promise { 22 | return new Promise(resolve => resolve(null)) 23 | } 24 | } 25 | 26 | describe('DbAccount Usecase', () => { 27 | const mockHasher = new MockHasher() as jest.Mocked 28 | const mockAddAccountRepository = new MockAddAccountRepository() as jest.Mocked 29 | const mockLoadAccountRepository = new MockLoadAccountRepository() as jest.Mocked 30 | const sut = new DbAddAccount(mockHasher, mockAddAccountRepository, mockLoadAccountRepository) 31 | 32 | describe('Hasher', () => { 33 | test('Should call Hasher with correct password', async () => { 34 | const hashSpy = jest.spyOn(mockHasher, 'hash') 35 | await sut.add(fakeAccountParams) 36 | expect(hashSpy).toHaveBeenCalledWith(fakeAccountParams.password) 37 | }) 38 | 39 | test('Should throw if Hasher throws', async () => { 40 | mockHasher.hash.mockRejectedValueOnce(new Error()) 41 | const accountPromise = sut.add(fakeAccountParams) 42 | await expect(accountPromise).rejects.toThrow() 43 | }) 44 | }) 45 | 46 | describe('AddAccount Repository', () => { 47 | test('Should call AddAccountRepository with correct values', async () => { 48 | const addSpy = jest.spyOn(mockAddAccountRepository, 'add') 49 | await sut.add(fakeAccountParams) 50 | expect(addSpy).toHaveBeenCalledWith({ ...fakeAccountParams, password: 'hashed_password' }) 51 | }) 52 | 53 | test('Should throw if AddAccountRepository throws', async () => { 54 | mockAddAccountRepository.add.mockRejectedValueOnce(new Error()) 55 | const accountPromise = sut.add(fakeAccountParams) 56 | await expect(accountPromise).rejects.toThrow() 57 | }) 58 | 59 | test('Should return an account on success', async () => { 60 | const account = await sut.add(fakeAccountParams) 61 | expect(account).toEqual(fakeAccountModel) 62 | }) 63 | }) 64 | 65 | describe('LoadAccount Repository', () => { 66 | test('Should call LoadAccountRepository with correct email', async () => { 67 | const loadByEmailSpy = jest.spyOn(mockLoadAccountRepository, 'loadByEmail') 68 | await sut.add(fakeAccountParams) 69 | expect(loadByEmailSpy).toHaveBeenCalledWith(fakeAccountParams.email) 70 | }) 71 | 72 | test('Should null if loadAccount not returns null', async () => { 73 | mockLoadAccountRepository.loadByEmail.mockResolvedValueOnce(fakeAccountModel) 74 | const account = await sut.add(fakeAccountParams) 75 | expect(account).toBeNull() 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/data/usecases/add-account/db-add-account.ts: -------------------------------------------------------------------------------- 1 | import { Hasher } from '@data/interfaces/cryptography/hasher' 2 | import { AddAccountRepository } from '@data/interfaces/db/account/add-account-repository' 3 | import { LoadAccountRepository } from '@data/interfaces/db/account/load-account-repository' 4 | import { AccountModel } from '@domain/models/account' 5 | import { AddAccount, AddAccountParams } from '@domain/usecases/add-account' 6 | 7 | export class DbAddAccount implements AddAccount { 8 | constructor( 9 | private readonly hasher: Hasher, 10 | private readonly addAccountRepository: AddAccountRepository, 11 | private readonly loadAccountRepository: LoadAccountRepository 12 | ) {} 13 | 14 | async add(accountData: AddAccountParams): Promise { 15 | const account = await this.loadAccountRepository.loadByEmail(accountData.email) 16 | if (!account) { 17 | const hashedPassword = await this.hasher.hash(accountData.password) 18 | const newAccount = await this.addAccountRepository.add({ 19 | ...accountData, 20 | password: hashedPassword 21 | }) 22 | 23 | return newAccount 24 | } 25 | 26 | return null 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/data/usecases/add-survey/db-add-survey.spec.ts: -------------------------------------------------------------------------------- 1 | import { DbAddSurvey } from './db-add-survey' 2 | import { AddSurveyRepository } from '../../interfaces/db/survey/add-survey-repository' 3 | import { AddSurveyParams } from '../../../domain/usecases/add-survey' 4 | import MockDate from 'mockdate' 5 | import { fakeSurveyParams as surveyParams } from '../../../domain/mocks/mock-survey' 6 | 7 | const fakeSurveyParams = { ...surveyParams, date: new Date() } 8 | 9 | class MockAddSurveyRepository implements AddSurveyRepository { 10 | async add(data: AddSurveyParams): Promise { 11 | return new Promise(resolve => resolve()) 12 | } 13 | } 14 | 15 | describe('DbAddSurvey Usecase', () => { 16 | const mockAddSurveyRepository = new MockAddSurveyRepository() as jest.Mocked 17 | const sut = new DbAddSurvey(mockAddSurveyRepository) 18 | 19 | beforeAll(() => MockDate.set(new Date())) 20 | afterAll(() => MockDate.reset()) 21 | 22 | test('Should call AddSurveyRepository with correct values ', () => { 23 | const addSpy = jest.spyOn(mockAddSurveyRepository, 'add') 24 | sut.add(fakeSurveyParams) 25 | expect(addSpy).toHaveBeenCalledWith(fakeSurveyParams) 26 | }) 27 | 28 | test('Should throw if AddSurveyRepository throws', async () => { 29 | mockAddSurveyRepository.add.mockRejectedValueOnce(new Error()) 30 | const promise = sut.add(fakeSurveyParams) 31 | await expect(promise).rejects.toThrow() 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/data/usecases/add-survey/db-add-survey.ts: -------------------------------------------------------------------------------- 1 | import { AddSurveyRepository } from '@data/interfaces/db/survey/add-survey-repository' 2 | import { AddSurvey, AddSurveyParams } from '@domain/usecases/add-survey' 3 | 4 | export class DbAddSurvey implements AddSurvey { 5 | constructor(private readonly addSurveyRepository: AddSurveyRepository) {} 6 | async add(data: AddSurveyParams): Promise { 7 | await this.addSurveyRepository.add(data) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/data/usecases/authentication/db-authentication.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fakeAccountModel, 3 | fakeLoginParams as fakeAuthParams 4 | } from '../../../domain/mocks/mock-account' 5 | import { AccountModel } from '../../../domain/models/account' 6 | import { Encrypter } from '../../interfaces/cryptography/encrypter' 7 | import { HashComparer } from '../../interfaces/cryptography/hash-comparer' 8 | import { AccessTokenRepository } from '../../interfaces/db/account/access-token-repository' 9 | import { LoadAccountRepository } from '../../interfaces/db/account/load-account-repository' 10 | import { DbAuthentication } from './db-authentication' 11 | 12 | class MockLoadAccountRepository implements LoadAccountRepository { 13 | async loadByEmail(email: string): Promise { 14 | return new Promise(resolve => resolve(fakeAccountModel)) 15 | } 16 | } 17 | 18 | class MockHashComparer implements HashComparer { 19 | async compare(value: string, hash: string): Promise { 20 | return new Promise(resolve => resolve(true)) 21 | } 22 | } 23 | 24 | class MockEncrypter implements Encrypter { 25 | async encrypt(value: string): Promise { 26 | return new Promise(resolve => resolve('any_token')) 27 | } 28 | } 29 | 30 | class MockAccessTokenRepository implements AccessTokenRepository { 31 | async storeAccessToken(id: string, token: string): Promise {} 32 | } 33 | 34 | describe('DbAuthentication Usecase', () => { 35 | const mockLoadAccountRepository = new MockLoadAccountRepository() as jest.Mocked 36 | const mockHashComparer = new MockHashComparer() as jest.Mocked 37 | const mockEncrypter = new MockEncrypter() as jest.Mocked 38 | const mockAccessTokenRepository = new MockAccessTokenRepository() as jest.Mocked 39 | const sut = new DbAuthentication( 40 | mockLoadAccountRepository, 41 | mockHashComparer, 42 | mockEncrypter, 43 | mockAccessTokenRepository 44 | ) 45 | 46 | describe('LoadAccount Repository', () => { 47 | test('Should call LoadAccountRepository with correct email', async () => { 48 | const loadByEmailSpy = jest.spyOn(mockLoadAccountRepository, 'loadByEmail') 49 | await sut.authenticate(fakeAuthParams) 50 | expect(loadByEmailSpy).toHaveBeenCalledWith(fakeAuthParams.email) 51 | }) 52 | 53 | test('Should throw if LoadAccountRepository throws', async () => { 54 | mockLoadAccountRepository.loadByEmail.mockRejectedValueOnce(new Error()) 55 | const promiseError = sut.authenticate(fakeAuthParams) 56 | await expect(promiseError).rejects.toThrow() 57 | }) 58 | 59 | test('Should return null if LoadAccountRepository returns null', async () => { 60 | mockLoadAccountRepository.loadByEmail.mockReturnValueOnce(null) 61 | const model = await sut.authenticate(fakeAuthParams) 62 | expect(model).toBeNull() 63 | }) 64 | }) 65 | 66 | describe('Hash Comparer', () => { 67 | test('Should call HashComparer with correct values', async () => { 68 | const compareSpy = jest.spyOn(mockHashComparer, 'compare') 69 | await sut.authenticate(fakeAuthParams) 70 | expect(compareSpy).toHaveBeenCalledWith(fakeAuthParams.password, fakeAccountModel.password) 71 | }) 72 | 73 | test('Should throw if HashComparer throws', async () => { 74 | mockHashComparer.compare.mockRejectedValueOnce(new Error()) 75 | const promiseError = sut.authenticate(fakeAuthParams) 76 | await expect(promiseError).rejects.toThrow() 77 | }) 78 | 79 | test('Should return null if HashComparer returns false', async () => { 80 | mockHashComparer.compare.mockResolvedValueOnce(false) 81 | const model = await sut.authenticate(fakeAuthParams) 82 | expect(model).toBeNull() 83 | }) 84 | }) 85 | 86 | describe('Encrypter', () => { 87 | test('Should call Encrypter with correct id', async () => { 88 | const generateSpy = jest.spyOn(mockEncrypter, 'encrypt') 89 | await sut.authenticate(fakeAuthParams) 90 | expect(generateSpy).toHaveBeenCalledWith(fakeAccountModel.id) 91 | }) 92 | 93 | test('Should throw if Encrypter throws', async () => { 94 | mockEncrypter.encrypt.mockRejectedValueOnce(new Error()) 95 | const promiseError = sut.authenticate(fakeAuthParams) 96 | await expect(promiseError).rejects.toThrow() 97 | }) 98 | 99 | test('Should return an authentication model on success', async () => { 100 | const { accessToken, name } = await sut.authenticate(fakeAuthParams) 101 | expect(accessToken).toBe('any_token') 102 | expect(name).toBe(fakeAccountModel.name) 103 | }) 104 | test('Should throw if Encrypter throws', async () => { 105 | mockEncrypter.encrypt.mockRejectedValueOnce(new Error()) 106 | 107 | const promiseError = sut.authenticate(fakeAuthParams) 108 | await expect(promiseError).rejects.toThrow() 109 | }) 110 | }) 111 | 112 | describe('AccessToken Repository', () => { 113 | test('Should call AccessTokenRepository with correct values', async () => { 114 | const storeAccessTokenSpy = jest.spyOn(mockAccessTokenRepository, 'storeAccessToken') 115 | await sut.authenticate(fakeAuthParams) 116 | expect(storeAccessTokenSpy).toHaveBeenCalledWith(fakeAccountModel.id, 'any_token') 117 | }) 118 | 119 | test('Should throw if AccessTokenRepository throws', async () => { 120 | mockAccessTokenRepository.storeAccessToken.mockRejectedValueOnce(new Error()) 121 | const promiseError = sut.authenticate(fakeAuthParams) 122 | await expect(promiseError).rejects.toThrow() 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /src/data/usecases/authentication/db-authentication.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationModel } from '@domain/models/account' 2 | import { AuthenticationParams, Authenticator } from '@domain/usecases/authentication' 3 | import { Encrypter } from '../../interfaces/cryptography/encrypter' 4 | import { HashComparer } from '../../interfaces/cryptography/hash-comparer' 5 | import { AccessTokenRepository } from '../../interfaces/db/account/access-token-repository' 6 | import { LoadAccountRepository } from '../../interfaces/db/account/load-account-repository' 7 | 8 | export class DbAuthentication implements Authenticator { 9 | constructor( 10 | private readonly loadAccountRepository: LoadAccountRepository, 11 | private readonly hashComparer: HashComparer, 12 | private readonly encrypter: Encrypter, 13 | private readonly accessTokenRepository: AccessTokenRepository 14 | ) {} 15 | 16 | async authenticate(data: AuthenticationParams): Promise { 17 | const account = await this.loadAccountRepository.loadByEmail(data.email) 18 | if (account) { 19 | const isValid = await this.hashComparer.compare(data.password, account.password) 20 | if (isValid) { 21 | const accessToken = await this.encrypter.encrypt(account.id) 22 | await this.accessTokenRepository.storeAccessToken(account.id, accessToken) 23 | return { accessToken, name: account.name } 24 | } 25 | } 26 | return null 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/data/usecases/load-account/db-load-account-by-token.spec.ts: -------------------------------------------------------------------------------- 1 | import { Decrypter } from '../../../data/interfaces/cryptography/decrypter' 2 | import { DbLoadAccountByToken } from './db-load-account-by-token' 3 | import { AccountModel } from '../../../domain/models/account' 4 | import { LoadAccountByTokenRepository } from '../../interfaces/db/account/load-account-by-token-repository' 5 | import { fakeAccountModel } from '../../../domain/mocks/mock-account' 6 | 7 | class MockDecrypter implements Decrypter { 8 | async decript(value: string): Promise { 9 | return 'any_token' 10 | } 11 | } 12 | 13 | class MockLoadAccountByTokenRepository implements LoadAccountByTokenRepository { 14 | async loadByToken(token: string, role?: string): Promise { 15 | return { ...fakeAccountModel, password: 'hashed_password' } 16 | } 17 | } 18 | 19 | describe('DbLoadAccountByToken', () => { 20 | const mockDecrypter = new MockDecrypter() as jest.Mocked 21 | const mockLoadAccountByTokenRepository = new MockLoadAccountByTokenRepository() as jest.Mocked 22 | const sut = new DbLoadAccountByToken(mockDecrypter, mockLoadAccountByTokenRepository) 23 | 24 | describe('Decrypter', () => { 25 | test('Should call decrypter with correct values', async () => { 26 | const decryptSpy = jest.spyOn(mockDecrypter, 'decript') 27 | await sut.load('any_token', 'any_role') 28 | expect(decryptSpy).toHaveBeenLastCalledWith('any_token') 29 | }) 30 | 31 | test('Should return null if decrypter returns null', async () => { 32 | mockDecrypter.decript.mockReturnValueOnce(null) 33 | const account = await sut.load('any_token', 'any_role') 34 | expect(account).toBeNull() 35 | }) 36 | 37 | test('Should throw if Decrypter throws', async () => { 38 | mockDecrypter.decript.mockRejectedValueOnce(new Error()) 39 | const account = await sut.load('any_token', 'any_role') 40 | expect(account).toBeNull() 41 | }) 42 | }) 43 | 44 | describe('LoadAccountByToken Repository', () => { 45 | test('Should call LoadAccountByTokenRepository with correct values', async () => { 46 | const loadSpy = jest.spyOn(mockLoadAccountByTokenRepository, 'loadByToken') 47 | await sut.load('any_token', 'any_role') 48 | expect(loadSpy).toHaveBeenLastCalledWith('any_token', 'any_role') 49 | }) 50 | 51 | test('Should return null if loadAccountRepository returns null', async () => { 52 | mockLoadAccountByTokenRepository.loadByToken.mockReturnValueOnce(null) 53 | const account = await sut.load('any_token', 'any_role') 54 | expect(account).toBeNull() 55 | }) 56 | 57 | test('Should return null if loadAccountRepository returns null', async () => { 58 | const account = await sut.load('any_token', 'any_role') 59 | expect(account).toEqual({ ...fakeAccountModel, password: 'hashed_password' }) 60 | }) 61 | 62 | test('Should throw if LoadAccountByTokenRepository throws', async () => { 63 | mockLoadAccountByTokenRepository.loadByToken.mockRejectedValueOnce(new Error()) 64 | const promise = sut.load('any_token', 'any_role') 65 | await expect(promise).rejects.toThrow() 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/data/usecases/load-account/db-load-account-by-token.ts: -------------------------------------------------------------------------------- 1 | import { Decrypter } from '@data/interfaces/cryptography/decrypter' 2 | import { LoadAccountByTokenRepository } from '@data/interfaces/db/account/load-account-by-token-repository' 3 | import { AccountModel } from '@domain/models/account' 4 | import { LoadAccountByToken } from '@domain/usecases/load-account-by-token' 5 | 6 | export class DbLoadAccountByToken implements LoadAccountByToken { 7 | constructor( 8 | private readonly decrypter: Decrypter, 9 | private readonly loadAccountByTokenRepository: LoadAccountByTokenRepository 10 | ) {} 11 | 12 | async load(accessToken: string, role?: string): Promise { 13 | let token: string = null 14 | try { 15 | token = await this.decrypter.decript(accessToken) 16 | } catch (error) { 17 | return null 18 | } 19 | 20 | if (token) { 21 | const account = await this.loadAccountByTokenRepository.loadByToken(accessToken, role) 22 | if (account) return account 23 | } 24 | return null 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/data/usecases/load-survey-by-id/load-survey-by-id.spec.ts: -------------------------------------------------------------------------------- 1 | import { SurveyModel } from '../../../domain/models/survey' 2 | import { LoadSurveyByIdRepository } from '../../interfaces/db/survey/load-survey-by-id-repository' 3 | import { DbLoadSurveyById } from './load-survey-by-id' 4 | import mockDate from 'mockdate' 5 | import { fakeSurveyModel } from '../../../domain/mocks/mock-survey' 6 | 7 | class MockLoadSurveyByIdRepository implements LoadSurveyByIdRepository { 8 | async loadById(id: string): Promise { 9 | return new Promise(resolve => resolve(fakeSurveyModel)) 10 | } 11 | } 12 | 13 | describe('DbLoadSurveyById', () => { 14 | const mockLoadSurveyByIdRepository = new MockLoadSurveyByIdRepository() as jest.Mocked 15 | const sut = new DbLoadSurveyById(mockLoadSurveyByIdRepository) 16 | 17 | beforeAll(() => mockDate.set(new Date())) 18 | afterAll(() => mockDate.reset()) 19 | 20 | test('Should call LoadSurveyRepository', async () => { 21 | const loadSpy = jest.spyOn(mockLoadSurveyByIdRepository, 'loadById') 22 | await sut.loadById('any_id') 23 | expect(loadSpy).toHaveBeenCalledWith('any_id') 24 | }) 25 | 26 | test('Should return a survey on success', async () => { 27 | const surveys = await sut.loadById('any_id') 28 | expect(surveys).toEqual(fakeSurveyModel) 29 | }) 30 | 31 | test('Should throw LoadSurveyByIdRepository throws', async () => { 32 | mockLoadSurveyByIdRepository.loadById.mockRejectedValueOnce(new Error()) 33 | const error = sut.loadById('any_id') 34 | await expect(error).rejects.toThrow() 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/data/usecases/load-survey-by-id/load-survey-by-id.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyByIdRepository } from '@data/interfaces/db/survey/load-survey-by-id-repository' 2 | import { SurveyModel } from '@domain/models/survey' 3 | 4 | export class DbLoadSurveyById implements LoadSurveyByIdRepository { 5 | constructor( 6 | private readonly loadSurveyByIdRepository: LoadSurveyByIdRepository 7 | ) {} 8 | 9 | async loadById(id: string): Promise { 10 | const survey = await this.loadSurveyByIdRepository.loadById(id) 11 | return survey 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/data/usecases/load-survey-result/db-load-survey-result.spec.ts: -------------------------------------------------------------------------------- 1 | import mockDate from 'mockdate' 2 | import { fakeSurveyModel } from '../../../domain/mocks/mock-survey' 3 | import { fakeSurveyResultModel } from '../../../domain/mocks/mock-survey-result' 4 | import { SurveyModel } from '../../../domain/models/survey' 5 | import { SurveyResultsModel } from '../../../domain/models/survey-results' 6 | import { LoadSurveyByIdRepository } from '../../interfaces/db/survey/load-survey-by-id-repository' 7 | import { LoadSurveyResultRepository } from '../../interfaces/db/survey/load-survey-results-repository' 8 | import { DbLoadSurveyResults } from './db-load-survey-result' 9 | 10 | class MockLoadSurveyResultsRepository implements LoadSurveyResultRepository { 11 | async loadBySurveyId(fakeSurveyId: string): Promise { 12 | return fakeSurveyResultModel 13 | } 14 | } 15 | 16 | class MockLoadSurveyByIdRepository implements LoadSurveyByIdRepository { 17 | async loadById(id: string): Promise { 18 | return fakeSurveyModel 19 | } 20 | } 21 | 22 | describe('DbLoadSurveyResult Usecase', () => { 23 | const mockLoadSurveyResultsRepository = new MockLoadSurveyResultsRepository() as jest.Mocked 24 | const mockLoadSurveyByIdRepository = new MockLoadSurveyByIdRepository() as jest.Mocked 25 | const sut = new DbLoadSurveyResults(mockLoadSurveyResultsRepository, mockLoadSurveyByIdRepository) 26 | const fakeSurveyId = 'any_surveyId' 27 | const fakeAccountId = 'any_id' 28 | 29 | beforeAll(() => mockDate.set('2021-03-08T19:16:46.313Z')) 30 | afterAll(() => mockDate.reset()) 31 | 32 | test('Should call LoadSurveyResultRepository with correct values', async () => { 33 | const load = jest.spyOn(mockLoadSurveyResultsRepository, 'loadBySurveyId') 34 | await sut.load(fakeSurveyId, fakeAccountId) 35 | expect(load).toHaveBeenCalledWith(fakeSurveyId, fakeAccountId) 36 | }) 37 | 38 | test('Should throw if SaveSurveyRepository throws', async () => { 39 | mockLoadSurveyResultsRepository.loadBySurveyId.mockRejectedValueOnce(new Error()) 40 | const error = sut.load(fakeSurveyId, fakeAccountId) 41 | await expect(error).rejects.toThrow() 42 | }) 43 | 44 | test('Should return a surveyResult model on success', async () => { 45 | const surveyResult = await sut.load(fakeSurveyId, fakeAccountId) 46 | expect(surveyResult).toEqual(fakeSurveyResultModel) 47 | }) 48 | 49 | describe('LoadSurveyById repository', () => { 50 | test('Should call LoadSurveyById repository if LoadSurveyResult repository returns null', async () => { 51 | mockLoadSurveyResultsRepository.loadBySurveyId.mockResolvedValueOnce(null) 52 | const loadSpy = jest.spyOn(mockLoadSurveyByIdRepository, 'loadById') 53 | await sut.load(fakeSurveyId, fakeAccountId) 54 | expect(loadSpy).toHaveBeenCalledWith(fakeSurveyId) 55 | }) 56 | 57 | test('Should return a surveyResult with all answers if LoadSurveyResult repository returns null', async () => { 58 | mockLoadSurveyResultsRepository.loadBySurveyId.mockResolvedValueOnce(null) 59 | const surveyResult = await sut.load(fakeSurveyId, fakeAccountId) 60 | expect(surveyResult).toEqual(fakeSurveyResultModel) 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/data/usecases/load-survey-result/db-load-survey-result.ts: -------------------------------------------------------------------------------- 1 | import { SurveyResultsModel } from '@domain/models/survey-results' 2 | import { LoadSurveyResult } from '@domain/usecases/load-survey-results' 3 | import { LoadSurveyResultRepository } from '@data/interfaces/db/survey/load-survey-results-repository' 4 | import { LoadSurveyByIdRepository } from '@data/interfaces/db/survey/load-survey-by-id-repository' 5 | 6 | export class DbLoadSurveyResults implements LoadSurveyResult { 7 | constructor( 8 | private readonly loadSurveyResultRepository: LoadSurveyResultRepository, 9 | private readonly loadSurveyByIdRepository: LoadSurveyByIdRepository 10 | ) {} 11 | 12 | async load(surveyId: string, accountId: string): Promise { 13 | let surveyResult = await this.loadSurveyResultRepository.loadBySurveyId(surveyId, accountId) 14 | if (!surveyResult) { 15 | const survey = await this.loadSurveyByIdRepository.loadById(surveyId) 16 | surveyResult = { 17 | surveyId: survey.id, 18 | date: survey.date, 19 | question: survey.question, 20 | answers: survey.answers.map(answer => 21 | Object.assign({}, answer, { count: 0, percent: 0, isCurrentAccountResponse: false }) 22 | ) 23 | } 24 | } 25 | return surveyResult 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/data/usecases/load-survey/db-load-survey.spec.ts: -------------------------------------------------------------------------------- 1 | import { SurveyModel } from '../../../domain/models/survey' 2 | import { LoadSurveyRepository } from '../../interfaces/db/survey/load-survey-repository' 3 | import { DbLoadSurvey } from './db-load-survey' 4 | import { fakeSurveysModel } from '../../../domain/mocks/mock-survey' 5 | 6 | class MockAdSurveyRepository implements LoadSurveyRepository { 7 | async loadAll(): Promise { 8 | return new Promise(resolve => resolve(fakeSurveysModel)) 9 | } 10 | } 11 | 12 | describe('DbLoadSurvey', () => { 13 | const mockAdSurveyRepository = new MockAdSurveyRepository() as jest.Mocked 14 | const sut = new DbLoadSurvey(mockAdSurveyRepository) 15 | const fakeId = 'any_id' 16 | 17 | test('Should call LoadSurveyRepository', async () => { 18 | const loadSpy = jest.spyOn(mockAdSurveyRepository, 'loadAll') 19 | await sut.load(fakeId) 20 | expect(loadSpy).toHaveBeenCalledWith(fakeId) 21 | }) 22 | 23 | test('Should return a list of surveys on success', async () => { 24 | const surveys = await sut.load(fakeId) 25 | expect(surveys).toEqual(fakeSurveysModel) 26 | }) 27 | 28 | test('Should throw LoadSurveyRepository throws', async () => { 29 | mockAdSurveyRepository.loadAll.mockRejectedValueOnce(new Error()) 30 | const error = sut.load(fakeId) 31 | await expect(error).rejects.toThrow() 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/data/usecases/load-survey/db-load-survey.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyRepository } from '@data/interfaces/db/survey/load-survey-repository' 2 | import { SurveyModel } from '@domain/models/survey' 3 | import { LoadSurvey } from '@domain/usecases/load-survey' 4 | 5 | export class DbLoadSurvey implements LoadSurvey { 6 | constructor(private readonly loadSurveyRepository: LoadSurveyRepository) {} 7 | 8 | async load(accountId: string): Promise { 9 | const surveys = await this.loadSurveyRepository.loadAll(accountId) 10 | return surveys 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/data/usecases/save-survey-result/db-save-survey-result.spec.ts: -------------------------------------------------------------------------------- 1 | import MockDate from 'mockdate' 2 | import { 3 | fakeSurveyResultModel, 4 | fakeSurveyResultParams 5 | } from '../../../domain/mocks/mock-survey-result' 6 | import { SurveyResultsModel } from '../../../domain/models/survey-results' 7 | import { SaveSurveyResultParams } from '../../../domain/usecases/save-survey-results' 8 | import { LoadSurveyResultRepository } from '../../interfaces/db/survey/load-survey-results-repository' 9 | import { SaveSurveyResultsRepository } from '../../interfaces/db/survey/save-survey-result-repository' 10 | import { DbSaveSurveyResults } from './db-save-survey-result' 11 | 12 | class MockSaveSurveyResultsRepository implements SaveSurveyResultsRepository { 13 | async save(data: SaveSurveyResultParams): Promise { 14 | return Promise.resolve() 15 | } 16 | } 17 | 18 | class MockLoadSurveyResultsRepository implements LoadSurveyResultRepository { 19 | async loadBySurveyId(surveyId: string, accountId: string): Promise { 20 | return fakeSurveyResultModel 21 | } 22 | } 23 | 24 | describe('DbSaveSurveyResults Usecase', () => { 25 | const mockSaveSurveyResultsRepository = new MockSaveSurveyResultsRepository() as jest.Mocked 26 | const mockLoadSurveyResultsRepository = new MockLoadSurveyResultsRepository() as jest.Mocked 27 | const sut = new DbSaveSurveyResults( 28 | mockSaveSurveyResultsRepository, 29 | mockLoadSurveyResultsRepository 30 | ) 31 | 32 | beforeAll(() => MockDate.set(new Date())) 33 | afterAll(() => MockDate.reset()) 34 | describe('SaveSurvey repository', () => { 35 | test('Should call SaveSurveyRepository with correct values ', async () => { 36 | const saveSpy = jest.spyOn(mockSaveSurveyResultsRepository, 'save') 37 | await sut.save(fakeSurveyResultParams) 38 | expect(saveSpy).toHaveBeenCalledWith(fakeSurveyResultParams) 39 | }) 40 | 41 | test('Should return a SurveyModel on success', async () => { 42 | const result = await sut.save(fakeSurveyResultParams) 43 | expect(result).toEqual(fakeSurveyResultModel) 44 | }) 45 | 46 | test('Should throw if SaveSurveyRepository throws', async () => { 47 | mockSaveSurveyResultsRepository.save.mockRejectedValueOnce(new Error()) 48 | const error = sut.save(fakeSurveyResultParams) 49 | await expect(error).rejects.toThrow() 50 | }) 51 | }) 52 | 53 | describe('LoadSurveyResult Repository', () => { 54 | test('Should call LoadSurveyRepository with correct values ', async () => { 55 | const loadSpy = jest.spyOn(mockLoadSurveyResultsRepository, 'loadBySurveyId') 56 | await sut.save(fakeSurveyResultParams) 57 | expect(loadSpy).toHaveBeenCalledWith( 58 | fakeSurveyResultParams.surveyId, 59 | fakeSurveyResultParams.accountId 60 | ) 61 | }) 62 | 63 | test('Should throw if LoadSurveyRepository throws', async () => { 64 | mockLoadSurveyResultsRepository.loadBySurveyId.mockRejectedValueOnce(new Error()) 65 | const error = sut.save(fakeSurveyResultParams) 66 | await expect(error).rejects.toThrow() 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/data/usecases/save-survey-result/db-save-survey-result.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyResultRepository } from '@data/interfaces/db/survey/load-survey-results-repository' 2 | import { SaveSurveyResultsRepository } from '@data/interfaces/db/survey/save-survey-result-repository' 3 | import { SurveyResultsModel } from '@domain/models/survey-results' 4 | import { SaveSurveyResults, SaveSurveyResultParams } from '@domain/usecases/save-survey-results' 5 | 6 | export class DbSaveSurveyResults implements SaveSurveyResults { 7 | constructor( 8 | private readonly saveSurveyResultRepository: SaveSurveyResultsRepository, 9 | private readonly loadSurveyResultsRepository: LoadSurveyResultRepository 10 | ) {} 11 | 12 | async save(data: SaveSurveyResultParams): Promise { 13 | await this.saveSurveyResultRepository.save(data) 14 | const surveyResults = await this.loadSurveyResultsRepository.loadBySurveyId( 15 | data.surveyId, 16 | data.accountId 17 | ) 18 | return surveyResults 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/mocks/mock-account.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '../models/account' 2 | import { AddAccountParams } from '../usecases/add-account' 3 | 4 | export const fakeAccountModel: AccountModel = { 5 | id: 'any_id', 6 | name: 'any_name', 7 | email: 'any@mail.com', 8 | password: 'any_password' 9 | } 10 | 11 | export const fakeAccountParams: AddAccountParams = { 12 | name: 'any_name', 13 | email: 'any@mail.com', 14 | password: 'any_password' 15 | } 16 | 17 | export const fakeLoginParams: Pick = { 18 | email: 'any@mail.com', 19 | password: 'any_password' 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/mocks/mock-survey-result.ts: -------------------------------------------------------------------------------- 1 | import { SurveyResultsModel } from '@domain/models/survey-results' 2 | import { SaveSurveyResultParams } from '@domain/usecases/save-survey-results' 3 | 4 | export const fakeSurveyResultModel: SurveyResultsModel = { 5 | surveyId: 'any_id', 6 | question: 'any_question', 7 | answers: [ 8 | { 9 | answer: 'any_answer', 10 | image: 'any_image', 11 | count: 0, 12 | percent: 0, 13 | isCurrentAccountResponse: false 14 | }, 15 | { 16 | answer: 'other_answer', 17 | image: 'other_image', 18 | count: 0, 19 | percent: 0, 20 | isCurrentAccountResponse: false 21 | } 22 | ], 23 | date: new Date('2021') 24 | } 25 | 26 | export const fakeSurveyResultParams: SaveSurveyResultParams = { 27 | date: new Date(), 28 | accountId: 'any_account_id', 29 | surveyId: 'any_survey_id', 30 | answer: 'any_answer' 31 | } 32 | -------------------------------------------------------------------------------- /src/domain/mocks/mock-survey.ts: -------------------------------------------------------------------------------- 1 | import { AddSurveyParams } from '@domain/usecases/add-survey' 2 | import { SurveyModel } from '../models/survey' 3 | 4 | export const fakeSurveyModel: SurveyModel = { 5 | id: 'any_id', 6 | date: new Date('2021'), 7 | question: 'any_question', 8 | answers: [ 9 | { image: 'any_image', answer: 'any_answer' }, 10 | { image: 'other_image', answer: 'other_answer' } 11 | ] 12 | } 13 | 14 | export const fakeSurveyParams: Omit = { 15 | question: 'any_question', 16 | answers: [ 17 | { image: 'any_image', answer: 'any_answer' }, 18 | { image: 'other_image', answer: 'other_answer' } 19 | ] 20 | } 21 | 22 | export const fakeSurveysModel: SurveyModel[] = [ 23 | { 24 | id: 'asdf', 25 | date: new Date(), 26 | question: 'any_question', 27 | answers: [{ image: 'any_image', answer: 'any_answer' }] 28 | }, 29 | { 30 | id: 'fdas', 31 | date: new Date(), 32 | question: 'other_question', 33 | answers: [{ image: 'other_image', answer: 'other_answer' }] 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /src/domain/models/account.ts: -------------------------------------------------------------------------------- 1 | export interface AccountModel { 2 | id: string 3 | name: string 4 | email: string 5 | password: string 6 | } 7 | 8 | export interface AuthenticationModel { 9 | accessToken: string 10 | name: string 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/models/survey-results.ts: -------------------------------------------------------------------------------- 1 | export interface SurveyResultAnswerModel { 2 | image?: string 3 | answer: string 4 | count: number 5 | percent: number 6 | isCurrentAccountResponse: boolean 7 | } 8 | 9 | export interface SurveyResultsModel { 10 | surveyId: string 11 | question: string 12 | answers: SurveyResultAnswerModel[] 13 | date: Date 14 | } 15 | -------------------------------------------------------------------------------- /src/domain/models/survey.ts: -------------------------------------------------------------------------------- 1 | export interface SurveyAnswer { 2 | image?: string 3 | answer: string 4 | } 5 | 6 | export interface SurveyModel { 7 | id: string 8 | question: string 9 | answers: SurveyAnswer[] 10 | date: Date 11 | didAnswer?: boolean 12 | } 13 | -------------------------------------------------------------------------------- /src/domain/usecases/add-account.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@domain/models/account' 2 | 3 | export type AddAccountParams = Omit 4 | 5 | export interface AddAccount { 6 | add(account: AddAccountParams): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/domain/usecases/add-survey.ts: -------------------------------------------------------------------------------- 1 | import { SurveyModel } from '@domain/models/survey' 2 | 3 | export interface AddSurveyParams extends Omit {} 4 | 5 | export interface AddSurvey { 6 | add(data: AddSurveyParams): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/domain/usecases/authentication.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationModel } from '@domain/models/account' 2 | 3 | export interface AuthenticationParams { 4 | email: string 5 | password: string 6 | } 7 | 8 | export interface Authenticator { 9 | authenticate(data: AuthenticationParams): Promise 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/usecases/load-account-by-token.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@domain/models/account' 2 | 3 | export interface LoadAccountByToken { 4 | load(accessToken: string, role?: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/usecases/load-survey-results.ts: -------------------------------------------------------------------------------- 1 | import { SurveyResultsModel } from '@domain/models/survey-results' 2 | 3 | export interface LoadSurveyResult { 4 | load(surveyId: string, accountId: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/usecases/load-survey.ts: -------------------------------------------------------------------------------- 1 | import { SurveyModel } from '@domain/models/survey' 2 | 3 | export interface LoadSurvey { 4 | load(accountId: string): Promise 5 | } 6 | 7 | export interface LoadSurveyById { 8 | loadById(id: string): Promise 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/usecases/save-survey-results.ts: -------------------------------------------------------------------------------- 1 | import { SurveyResultsModel } from '@domain/models/survey-results' 2 | 3 | export interface SaveSurveyResultParams { 4 | surveyId: string 5 | accountId: string 6 | answer: string 7 | date: Date 8 | } 9 | 10 | export interface SaveSurveyResults { 11 | save(data: SaveSurveyResultParams): Promise 12 | } 13 | -------------------------------------------------------------------------------- /src/infra/cryptography/bcrypt-adapter/bcrypt-adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt' 2 | import { BcryptAdapter } from './bcrypt-adapter' 3 | 4 | jest.mock('bcrypt', () => ({ 5 | async hash(): Promise { 6 | return new Promise(resolve => resolve('hash')) 7 | }, 8 | async compare(): Promise { 9 | return new Promise(resolve => resolve(true)) 10 | } 11 | })) 12 | 13 | describe('Bcrypt Adapter', () => { 14 | const salt = 12 15 | const sut = new BcryptAdapter(salt) 16 | 17 | describe('Hash', () => { 18 | test('Should call hash with correct values', async () => { 19 | const hashSpy = jest.spyOn(bcrypt, 'hash') 20 | await sut.hash('value') 21 | expect(hashSpy).toHaveBeenLastCalledWith('value', salt) 22 | }) 23 | 24 | test('Should return a valid hash on hash success', async () => { 25 | const hash = await sut.hash('value') 26 | expect(hash).toBe('hash') 27 | }) 28 | 29 | test('Should throw if hash throws', async () => { 30 | jest.spyOn(bcrypt, 'hash').mockRejectedValueOnce(new Error()) 31 | const hashPromise = sut.hash('value') 32 | await expect(hashPromise).rejects.toThrow() 33 | }) 34 | }) 35 | 36 | describe('Hash Comparer', () => { 37 | test('Should call compare with correct values', async () => { 38 | const compareSpy = jest.spyOn(bcrypt, 'compare') 39 | await sut.compare('any_value', 'any_hash') 40 | expect(compareSpy).toHaveBeenLastCalledWith('any_value', 'any_hash') 41 | }) 42 | 43 | test('Should return true when compare succeeds', async () => { 44 | const isValid = await sut.compare('any_value', 'any_hash') 45 | expect(isValid).toBe(true) 46 | }) 47 | 48 | test('Should return false when compare fails', async () => { 49 | jest.spyOn(bcrypt, 'compare').mockResolvedValueOnce(false) 50 | const isValid = await sut.compare('any_value', 'any_hash') 51 | expect(isValid).toBe(false) 52 | }) 53 | 54 | test('Should throw if compre throws', async () => { 55 | jest.spyOn(bcrypt, 'compare').mockRejectedValueOnce(new Error()) 56 | const isValid = sut.compare('any_value', 'any_hash') 57 | await expect(isValid).rejects.toThrow() 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /src/infra/cryptography/bcrypt-adapter/bcrypt-adapter.ts: -------------------------------------------------------------------------------- 1 | import { hash, compare } from 'bcrypt' 2 | import { Hasher } from '@data/interfaces/cryptography/hasher' 3 | import { HashComparer } from '@data/interfaces/cryptography/hash-comparer' 4 | 5 | export class BcryptAdapter implements Hasher, HashComparer { 6 | constructor(private readonly salt: number) {} 7 | 8 | async hash(value: string): Promise { 9 | const generatedHash = await hash(value, this.salt) 10 | return generatedHash 11 | } 12 | 13 | async compare(value: string, hash: string): Promise { 14 | const isValid = await compare(value, hash) 15 | return isValid 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/infra/cryptography/jwt-adapter/jwt-adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { JwtAdapter } from './jwt-adapter' 3 | 4 | jest.mock('jsonwebtoken', () => ({ 5 | async sign(): Promise { 6 | return new Promise(resolve => resolve('any_token')) 7 | }, 8 | 9 | async verify(): Promise { 10 | return new Promise(resolve => resolve('any_value')) 11 | } 12 | })) 13 | 14 | describe('Jwt Adapter', () => { 15 | const sut = new JwtAdapter('secret') 16 | 17 | describe('Encrypter', () => { 18 | test('Should call sign with correct values', async () => { 19 | const signSpy = jest.spyOn(jwt, 'sign') 20 | await sut.encrypt('any_value') 21 | expect(signSpy).toHaveBeenCalledWith({ id: 'any_value' }, 'secret') 22 | }) 23 | 24 | test('Should return a token on sign success', async () => { 25 | const token = await sut.encrypt('any_value') 26 | expect(token).toBe('any_token') 27 | }) 28 | 29 | test('Should throw if sign throws', async () => { 30 | jest.spyOn(jwt, 'sign').mockImplementationOnce(() => { 31 | throw new Error() 32 | }) 33 | const errorPromise = sut.encrypt('any_value') 34 | await expect(errorPromise).rejects.toThrow() 35 | }) 36 | }) 37 | 38 | describe('Decrypter', () => { 39 | test('Should call verify with correct values', async () => { 40 | const verifySpy = jest.spyOn(jwt, 'verify') 41 | await sut.decript('any_token') 42 | expect(verifySpy).toHaveBeenCalledWith('any_token', 'secret') 43 | }) 44 | 45 | test('Should return a value on verify success', async () => { 46 | const value = await sut.decript('any_token') 47 | expect(value).toBe('any_value') 48 | }) 49 | 50 | test('Should throw if verify throws', async () => { 51 | jest.spyOn(jwt, 'verify').mockImplementationOnce(() => { 52 | throw new Error() 53 | }) 54 | const errorPromise = sut.decript('any_token') 55 | await expect(errorPromise).rejects.toThrow() 56 | }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/infra/cryptography/jwt-adapter/jwt-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Decrypter } from '@data/interfaces/cryptography/decrypter' 2 | import { Encrypter } from '@data/interfaces/cryptography/encrypter' 3 | import jwt from 'jsonwebtoken' 4 | 5 | export class JwtAdapter implements Encrypter, Decrypter { 6 | constructor(private readonly secret: string) {} 7 | 8 | async encrypt(value: string): Promise { 9 | const token = jwt.sign({ id: value }, this.secret) 10 | return token 11 | } 12 | 13 | async decript(token: string): Promise { 14 | const value: any = jwt.verify(token, this.secret) 15 | return value 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/account/mongo-account-repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb' 2 | import { MongoHelper } from '../helpers/mongo' 3 | import { MongoAccountRepository } from './mongo-account-repository' 4 | 5 | describe('Account Mongodb Repository', () => { 6 | const sut = new MongoAccountRepository() 7 | let accountCollection: Collection 8 | const fakeAccount = { name: 'any_name', email: 'any@mail.com', password: 'any_password' } 9 | 10 | beforeAll(async () => { 11 | await MongoHelper.connect(process.env.MONGO_URL) 12 | }) 13 | afterAll(async () => { 14 | await MongoHelper.disconnect() 15 | }) 16 | beforeEach(async () => { 17 | accountCollection = await MongoHelper.getCollection('accounts') 18 | await accountCollection.deleteMany({}) 19 | }) 20 | 21 | describe('AddAccount', () => { 22 | test('Should return an account on add success', async () => { 23 | const account = await sut.add(fakeAccount) 24 | expect(account).toBeTruthy() 25 | expect(account.id).toBeTruthy() 26 | expect(account.email).toBe('any@mail.com') 27 | }) 28 | }) 29 | 30 | describe('LoadByEmail', () => { 31 | test('Should return an account on loadByEmail success', async () => { 32 | accountCollection.insertOne({ ...fakeAccount }) 33 | const account = await sut.loadByEmail('any@mail.com') 34 | expect(account).toBeTruthy() 35 | expect(account.id).toBeTruthy() 36 | expect(account.email).toBe('any@mail.com') 37 | }) 38 | 39 | test('Should return null if loadByEmail fails', async () => { 40 | const account = await sut.loadByEmail('any@mail.com') 41 | expect(account).toBeFalsy() 42 | }) 43 | }) 44 | 45 | describe('AccessToken', () => { 46 | test('Should create the account accessToken on AccessTokenSuccess', async () => { 47 | const result = await accountCollection.insertOne({ ...fakeAccount }) 48 | const newAccount = result.ops[0] 49 | expect(newAccount.accessToken).toBeFalsy() 50 | 51 | await sut.storeAccessToken(newAccount._id, 'any_token') 52 | const account = await accountCollection.findOne({ _id: newAccount._id }) 53 | expect(account).toBeTruthy() 54 | expect(account.accessToken).toBeTruthy() 55 | }) 56 | }) 57 | 58 | describe('LoadByToken', () => { 59 | test('Should return an account on loadByToken success without role', async () => { 60 | accountCollection.insertOne({ ...fakeAccount, accessToken: 'any_token' }) 61 | const account = await sut.loadByToken('any_token') 62 | expect(account).toBeTruthy() 63 | expect(account.id).toBeTruthy() 64 | expect(account.email).toBe('any@mail.com') 65 | }) 66 | 67 | test('Should return an account on loadByToken success with role', async () => { 68 | accountCollection.insertOne({ ...fakeAccount, accessToken: 'any_token', role: 'any_role' }) 69 | const account = await sut.loadByToken('any_token', 'any_role') 70 | expect(account).toBeTruthy() 71 | expect(account.id).toBeTruthy() 72 | expect(account.email).toBe('any@mail.com') 73 | }) 74 | 75 | test('Should return an account on loadByToken success with adm role', async () => { 76 | accountCollection.insertOne({ ...fakeAccount, accessToken: 'any_token', role: 'adm' }) 77 | const account = await sut.loadByToken('any_token', 'adm') 78 | expect(account).toBeTruthy() 79 | expect(account.id).toBeTruthy() 80 | expect(account.email).toBe('any@mail.com') 81 | }) 82 | 83 | test('Should return null on loadByToken success with invalid role', async () => { 84 | accountCollection.insertOne({ ...fakeAccount, accessToken: 'any_token' }) 85 | const account = await sut.loadByToken('any_token', 'adm') 86 | expect(account).toBeFalsy() 87 | }) 88 | 89 | test('Should return an account on loadByToken success if user is adm', async () => { 90 | accountCollection.insertOne({ ...fakeAccount, accessToken: 'any_token', role: 'adm' }) 91 | const account = await sut.loadByToken('any_token') 92 | expect(account).toBeTruthy() 93 | expect(account.id).toBeTruthy() 94 | expect(account.email).toBe('any@mail.com') 95 | }) 96 | 97 | test('Should return null if loadByToken fails', async () => { 98 | const account = await sut.loadByToken('any_token') 99 | expect(account).toBeFalsy() 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/account/mongo-account-repository.ts: -------------------------------------------------------------------------------- 1 | import { AddAccountRepository } from '@data/interfaces/db/account/add-account-repository' 2 | import { AddAccountParams } from '@domain/usecases/add-account' 3 | import { AccountModel } from '@domain/models/account' 4 | import { MongoHelper } from '../helpers/index' 5 | import { LoadAccountRepository } from '@data/interfaces/db/account/load-account-repository' 6 | import { AccessTokenRepository } from '@data/interfaces/db/account/access-token-repository' 7 | import { LoadAccountByTokenRepository } from '@data/interfaces/db/account/load-account-by-token-repository' 8 | 9 | export class MongoAccountRepository // eslint-disable-next-line indent 10 | implements 11 | AddAccountRepository, 12 | LoadAccountRepository, 13 | AccessTokenRepository, 14 | LoadAccountByTokenRepository { 15 | async add(accountData: AddAccountParams): Promise { 16 | const accountCollection = await MongoHelper.getCollection('accounts') 17 | const result = await accountCollection.insertOne(accountData) 18 | const account = result.ops[0] // account inserted 19 | 20 | return MongoHelper.map(account) 21 | } 22 | 23 | async loadByEmail(email: string): Promise { 24 | const accountCollection = await MongoHelper.getCollection('accounts') 25 | const account = await accountCollection.findOne({ email }) 26 | 27 | return account !== null ? MongoHelper.map(account) : null 28 | } 29 | 30 | async storeAccessToken(id: string, token: string): Promise { 31 | const accountCollection = await MongoHelper.getCollection('accounts') 32 | await accountCollection.updateOne( 33 | { _id: id }, 34 | { 35 | $set: { accessToken: token } 36 | } 37 | ) 38 | } 39 | 40 | async loadByToken(token: string, role?: string): Promise { 41 | const accountCollection = await MongoHelper.getCollection('accounts') 42 | const account = await accountCollection.findOne({ 43 | accessToken: token, 44 | $or: [{ role }, { role: 'adm' }] 45 | }) 46 | 47 | return account !== null ? MongoHelper.map(account) : null 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mongo' 2 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/helpers/mongo.spec.ts: -------------------------------------------------------------------------------- 1 | import { MongoHelper as sut } from './mongo' 2 | 3 | describe('Mongo Helper', () => { 4 | beforeAll(async () => await sut.connect(process.env.MONGO_URL)) 5 | afterAll(async () => await sut.disconnect()) 6 | 7 | test('Should reconnect if mongodb is down', async () => { 8 | let accountCollection = await sut.getCollection('accounts') 9 | expect(accountCollection).toBeTruthy() 10 | await sut.disconnect() 11 | accountCollection = await sut.getCollection('accounts') 12 | expect(accountCollection).toBeTruthy() 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/helpers/mongo.ts: -------------------------------------------------------------------------------- 1 | import { Collection, MongoClient } from 'mongodb' 2 | 3 | export const MongoHelper = { 4 | client: null as MongoClient, 5 | url: null as string, 6 | 7 | async connect(url: string): Promise { 8 | this.url = url 9 | this.client = await MongoClient.connect(url, { 10 | useNewUrlParser: true, 11 | useUnifiedTopology: true 12 | }) 13 | }, 14 | 15 | async disconnect(): Promise { 16 | await this.client.close() 17 | this.client = null 18 | }, 19 | 20 | async getCollection(name: string): Promise { 21 | if (!this.client?.isConnected()) await this.connect(this.url) 22 | return this.client.db().collection(name) 23 | }, 24 | 25 | // Change the "_id" returned from Mongodb to "id" 26 | map(data: any): any { 27 | const { _id, ...presentationCollection } = data 28 | return Object.assign({}, presentationCollection, { id: _id }) 29 | }, 30 | 31 | mapCollection(collection: any[]): any[] { 32 | return collection.map(item => MongoHelper.map(item)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/helpers/query-bulder.ts: -------------------------------------------------------------------------------- 1 | export class QueryBuilder { 2 | private readonly query = [] 3 | 4 | match(data: object): QueryBuilder { 5 | this.query.push({ $match: data }) 6 | return this 7 | } 8 | 9 | unwind(data: object): QueryBuilder { 10 | this.query.push({ $unwind: data }) 11 | return this 12 | } 13 | 14 | lookup(data: object): QueryBuilder { 15 | this.query.push({ $lookup: data }) 16 | return this 17 | } 18 | 19 | project(data: object): QueryBuilder { 20 | this.query.push({ $project: data }) 21 | return this 22 | } 23 | 24 | group(data: object): QueryBuilder { 25 | this.query.push({ $group: data }) 26 | return this 27 | } 28 | 29 | sort(data: object): QueryBuilder { 30 | this.query.push({ $sort: data }) 31 | return this 32 | } 33 | 34 | build(): object[] { 35 | return this.query 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/log/mongo-log-repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb' 2 | import { MongoHelper } from '../helpers/mongo' 3 | import { MongoLogRepository } from './mongo-log-repository' 4 | 5 | describe('Log Mongo Repository', () => { 6 | const sut = new MongoLogRepository() 7 | let errorCollection: Collection 8 | 9 | beforeAll(async () => { 10 | await MongoHelper.connect(process.env.MONGO_URL) 11 | }) 12 | afterAll(async () => { 13 | await MongoHelper.disconnect() 14 | }) 15 | beforeEach(async () => { 16 | errorCollection = await MongoHelper.getCollection('errors') 17 | await errorCollection.deleteMany({}) 18 | }) 19 | 20 | test('Should create an error log on success', async () => { 21 | await sut.logError('any_stack') 22 | const count = await errorCollection.countDocuments() 23 | expect(count).toBe(1) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/log/mongo-log-repository.ts: -------------------------------------------------------------------------------- 1 | import { LogErrorRepository } from '@data/interfaces/db/log/log-error-repository' 2 | import { MongoHelper } from '../helpers/mongo' 3 | 4 | export class MongoLogRepository implements LogErrorRepository { 5 | async logError(stack: string): Promise { 6 | const errorCollection = await MongoHelper.getCollection('errors') 7 | await errorCollection.insertOne({ 8 | stack, 9 | date: new Date() 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/survey-result/mongo-survey-results-repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection, ObjectId } from 'mongodb' 2 | import { fakeAccountModel } from '../../../../domain/mocks/mock-account' 3 | import { fakeSurveyModel } from '../../../../domain/mocks/mock-survey' 4 | import { AccountModel } from '../../../../domain/models/account' 5 | import { SurveyModel } from '../../../../domain/models/survey' 6 | import { MongoHelper } from '../helpers/mongo' 7 | import { MongoSurveyResultRepository } from './mongo-survey-results-repository' 8 | 9 | let surveyCollection: Collection 10 | let surveyResultsCollection: Collection 11 | let accountCollection: Collection 12 | 13 | const makeSurvey = async (): Promise => { 14 | const res = await surveyCollection.insertOne({ 15 | question: 'any_question', 16 | answers: [ 17 | { 18 | image: 'any_image', 19 | answer: 'any_answer_1' 20 | }, 21 | { 22 | answer: 'any_answer_2' 23 | }, 24 | { 25 | answer: 'any_answer_3' 26 | } 27 | ], 28 | date: new Date() 29 | }) 30 | return MongoHelper.map(res.ops[0]) 31 | } 32 | 33 | const makeAccount = async (): Promise => { 34 | const res = await accountCollection.insertOne({ 35 | name: 'any_name', 36 | email: 'any_email@mail.com', 37 | password: 'any_password' 38 | }) 39 | return MongoHelper.map(res.ops[0]) 40 | } 41 | 42 | describe('Survey Mongo Repository', () => { 43 | const sut = new MongoSurveyResultRepository() 44 | beforeAll(async () => { 45 | await MongoHelper.connect(process.env.MONGO_URL) 46 | }) 47 | 48 | afterAll(async () => { 49 | await MongoHelper.disconnect() 50 | }) 51 | 52 | beforeEach(async () => { 53 | surveyCollection = await MongoHelper.getCollection('surveys') 54 | await surveyCollection.deleteMany({}) 55 | surveyResultsCollection = await MongoHelper.getCollection('surveyResults') 56 | await surveyResultsCollection.deleteMany({}) 57 | accountCollection = await MongoHelper.getCollection('accounts') 58 | await accountCollection.deleteMany({}) 59 | }) 60 | 61 | describe('save()', () => { 62 | test('Should add a survey result if its new', async () => { 63 | const survey = await makeSurvey() 64 | const account = await makeAccount() 65 | await sut.save({ 66 | surveyId: survey.id, 67 | accountId: account.id, 68 | answer: survey.answers[0].answer, 69 | date: new Date() 70 | }) 71 | const surveyResult = await surveyResultsCollection.findOne({ 72 | surveyId: survey.id, 73 | accountId: account.id 74 | }) 75 | expect(surveyResult).toBeTruthy() 76 | }) 77 | 78 | test('Should update a survey result if its not new', async () => { 79 | const survey = await makeSurvey() 80 | const account = await makeAccount() 81 | await surveyResultsCollection.insertOne({ 82 | surveyId: new ObjectId(survey.id), 83 | accountId: new ObjectId(account.id), 84 | answer: survey.answers[0].answer, 85 | date: new Date() 86 | }) 87 | await sut.save({ 88 | surveyId: survey.id, 89 | accountId: account.id, 90 | answer: survey.answers[1].answer, 91 | date: new Date() 92 | }) 93 | const surveyResult = await surveyResultsCollection 94 | .find({ 95 | surveyId: survey.id, 96 | accountId: account.id 97 | }) 98 | .toArray() 99 | expect(surveyResult).toBeTruthy() 100 | expect(surveyResult.length).toBe(1) 101 | }) 102 | }) 103 | 104 | describe('LoadSurveyResult', () => { 105 | test('Should load a survey result', async () => { 106 | const insertedSurvey = await surveyCollection.insertOne(fakeSurveyModel) 107 | const survey = insertedSurvey.ops[0] 108 | 109 | const insertedAccount = await accountCollection.insertOne(fakeAccountModel) 110 | const account = insertedAccount.ops[0] 111 | 112 | await surveyResultsCollection.insertMany([ 113 | { 114 | surveyId: new ObjectId(survey._id), 115 | answer: survey.answers[0].answer, 116 | accountId: new ObjectId(account._id), 117 | date: new Date() 118 | }, 119 | { 120 | surveyId: new ObjectId(survey._id), 121 | answer: survey.answers[1].answer, 122 | accountId: new ObjectId(account._id), 123 | date: new Date() 124 | } 125 | ]) 126 | 127 | const surveyResults = await sut.loadBySurveyId(survey._id, account._id) 128 | expect(surveyResults).toBeTruthy() 129 | expect(surveyResults.surveyId).toEqual(survey._id) 130 | expect(surveyResults.answers[0].count).toBe(1) 131 | expect(surveyResults.answers[0].percent).toBe(50) 132 | expect(surveyResults.answers[1].count).toBe(1) 133 | expect(surveyResults.answers[1].percent).toBe(50) 134 | }) 135 | }) 136 | 137 | test('Should return null if there is no surveys results', async () => { 138 | const insertedSurvey = await surveyCollection.insertOne(fakeSurveyModel) 139 | const insertedAccount = await surveyCollection.insertOne(fakeAccountModel) 140 | const account = insertedAccount.ops[0] 141 | const survey = insertedSurvey.ops[0] 142 | const surveyResults = await sut.loadBySurveyId(survey._id, account._id) 143 | expect(surveyResults).toBeNull() 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/survey-result/mongo-survey-results-repository.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyResultRepository } from '@data/interfaces/db/survey/load-survey-results-repository' 2 | import { SaveSurveyResultsRepository } from '@data/interfaces/db/survey/save-survey-result-repository' 3 | import { SurveyResultsModel } from '@domain/models/survey-results' 4 | import { SaveSurveyResultParams } from '@domain/usecases/save-survey-results' 5 | import { ObjectId } from 'mongodb' 6 | import { MongoHelper } from '../helpers/mongo' 7 | import { QueryBuilder } from '../helpers/query-bulder' 8 | 9 | export class MongoSurveyResultRepository 10 | implements SaveSurveyResultsRepository, LoadSurveyResultRepository { 11 | async save(data: SaveSurveyResultParams): Promise { 12 | const surveyResultCollection = await MongoHelper.getCollection('surveyResults') 13 | await surveyResultCollection.findOneAndUpdate( 14 | { surveyId: new ObjectId(data.surveyId), accountId: new ObjectId(data.accountId) }, 15 | { $set: { answer: data.answer, date: data.date } }, 16 | { upsert: true } 17 | ) 18 | } 19 | 20 | async loadBySurveyId(surveyId: string, accountId: string): Promise { 21 | const surveyResultCollection = await MongoHelper.getCollection('surveyResults') 22 | const query = new QueryBuilder() 23 | .match({ 24 | surveyId: new ObjectId(surveyId) 25 | }) 26 | .group({ _id: 0, data: { $push: '$$ROOT' }, total: { $sum: 1 } }) 27 | .unwind({ path: '$data' }) 28 | .lookup({ 29 | from: 'surveys', 30 | foreignField: '_id', 31 | localField: 'data.surveyId', 32 | as: 'survey' 33 | }) 34 | .unwind({ path: '$survey' }) 35 | .group({ 36 | _id: { 37 | surveyId: '$survey._id', 38 | question: '$survey.question', 39 | date: '$survey.date', 40 | total: '$total', 41 | answer: '$data.answer', 42 | answers: '$survey.answers' 43 | }, 44 | count: { $sum: 1 }, 45 | currentAccountResponse: { 46 | $push: { $cond: [{ $eq: ['$data.accountId', accountId] }, '$data.answer', null] } 47 | } 48 | }) 49 | .project({ 50 | _id: 0, 51 | surveyId: '$_id.surveyId', 52 | question: '$_id.question', 53 | date: '$_id.date', 54 | answers: { 55 | $map: { 56 | input: '$_id.answers', 57 | as: 'item', 58 | in: { 59 | $mergeObjects: [ 60 | '$$item', 61 | { 62 | count: { 63 | $cond: { 64 | if: { $eq: ['$$item.answer', '$_id.answer'] }, 65 | then: '$count', 66 | else: 0 67 | } 68 | }, 69 | percent: { 70 | $cond: { 71 | if: { $eq: ['$$item.answer', '$_id.answer'] }, 72 | then: { 73 | $multiply: [{ $divide: ['$count', '$_id.total'] }, 100] 74 | }, 75 | else: 0 76 | } 77 | }, 78 | isCurrentAccountResponse: { 79 | $eq: ['$$item.answer', { $arrayElemAt: ['$currentAccountResponse', 0] }] 80 | } 81 | } 82 | ] 83 | } 84 | } 85 | } 86 | }) 87 | .group({ 88 | _id: { surveyId: '$surveyId', question: '$question', date: '$date' }, 89 | answers: { $push: '$answers' } 90 | }) 91 | .project({ 92 | _id: 0, 93 | surveyId: '$_id.surveyId', 94 | question: '$_id.question', 95 | date: '$_id.date', 96 | answers: { 97 | $reduce: { 98 | input: '$answers', 99 | initialValue: [], 100 | in: { $concatArrays: ['$$value', '$$this'] } 101 | } 102 | } 103 | }) 104 | .unwind({ path: '$answers' }) 105 | .group({ 106 | _id: { 107 | surveyId: '$surveyId', 108 | question: '$question', 109 | date: '$date', 110 | answer: '$answers.answer', 111 | image: '$answers.image', 112 | isCurrentAccountResponse: '$answers.isCurrentAccountAnswer' 113 | }, 114 | count: { $sum: '$answers.count' }, 115 | percent: { $sum: '$answers.percent' } 116 | }) 117 | .project({ 118 | _id: 0, 119 | surveyId: '$_id.surveyId', 120 | question: '$_id.question', 121 | date: '$_id.date', 122 | answer: { 123 | answer: '$_id.answer', 124 | image: '$_id.image', 125 | count: { $round: ['$count'] }, 126 | percent: '$percent', 127 | isCurrentAccountResponse: '$_id.isCurrentAccountAnswer' 128 | } 129 | }) 130 | .sort({ 'answer.count': -1 }) 131 | .group({ 132 | _id: { 133 | surveyId: '$surveyId', 134 | question: '$question', 135 | date: '$date' 136 | }, 137 | answers: { $push: '$answer' } 138 | }) 139 | .project({ 140 | _id: 0, 141 | surveyId: '$_id.surveyId', 142 | question: '$_id.question', 143 | date: '$_id.date', 144 | answers: '$answers' 145 | }) 146 | .build() 147 | 148 | const surveyResult = await surveyResultCollection.aggregate(query).toArray() 149 | return surveyResult.length ? surveyResult[0] : null 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/survey/mongo-survey-repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'mongodb' 2 | import { fakeSurveyParams, fakeSurveysModel } from '../../../../domain/mocks/mock-survey' 3 | import { AccountModel } from '../../../../domain/models/account' 4 | import { MongoHelper } from '../helpers/mongo' 5 | import { MongoSurveyRepository } from './mongo-survey-repository' 6 | 7 | let surveyCollection: Collection 8 | let surveyResultCollection: Collection 9 | let accountCollection: Collection 10 | 11 | const makeAccount = async (): Promise => { 12 | const res = await accountCollection.insertOne({ 13 | name: 'any_name', 14 | email: 'any_email@mail.com', 15 | password: 'any_password' 16 | }) 17 | return MongoHelper.map(res.ops[0]) 18 | } 19 | 20 | describe('Survey Mongodb Repository', () => { 21 | const sut = new MongoSurveyRepository() 22 | 23 | beforeAll(async () => await MongoHelper.connect(process.env.MONGO_URL)) 24 | afterAll(async () => await MongoHelper.disconnect()) 25 | beforeEach(async () => { 26 | surveyCollection = await MongoHelper.getCollection('surveys') 27 | accountCollection = await MongoHelper.getCollection('account') 28 | surveyResultCollection = await MongoHelper.getCollection('surveyResult') 29 | await surveyCollection.deleteMany({}) 30 | await surveyResultCollection.deleteMany({}) 31 | await accountCollection.deleteMany({}) 32 | }) 33 | 34 | describe('AddSurvey', () => { 35 | test('Should add a survey on success', async () => { 36 | await sut.add({ ...fakeSurveyParams, date: new Date() }) 37 | const survey = await surveyCollection.findOne({ question: 'any_question' }) 38 | expect(survey).toBeTruthy() 39 | }) 40 | }) 41 | 42 | describe('LoadAllSurveys', () => { 43 | test('Should load all surveys on success', async () => { 44 | const account = await makeAccount() 45 | const result = await surveyCollection.insertMany(fakeSurveysModel) 46 | const survey = result.ops[0] 47 | await surveyResultCollection.insertOne({ 48 | accountId: account.id, 49 | date: new Date(), 50 | surveyId: survey._id, 51 | answer: survey.answers[0].answer 52 | }) 53 | const surveys = await sut.loadAll(account.id) 54 | expect(surveys.length).toBe(2) 55 | expect(surveys[0].id).toBeTruthy() 56 | expect(surveys[0].didAnswer).toBeTruthy() 57 | expect(surveys[1].id).toBeTruthy() 58 | expect(surveys[1].didAnswer).toBeFalsy() 59 | }) 60 | 61 | test('Should load an empty list if there are not surveys registered', async () => { 62 | const account = await makeAccount() 63 | const surveys = await sut.loadAll(account.id) 64 | expect(surveys.length).toBe(0) 65 | expect(surveys).toBeInstanceOf(Array) 66 | }) 67 | }) 68 | 69 | describe('LoadSurveyById', () => { 70 | test('Should load a survey by id on success', async () => { 71 | const response = await surveyCollection.insertOne({ ...fakeSurveyParams }) 72 | 73 | const survey = await sut.loadById(response.ops[0]._id) 74 | expect(survey).toBeTruthy() 75 | expect(survey.question).toBe('any_question') 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/infra/db/mongodb/survey/mongo-survey-repository.ts: -------------------------------------------------------------------------------- 1 | import { AddSurveyRepository } from '@data/interfaces/db/survey/add-survey-repository' 2 | import { LoadSurveyByIdRepository } from '@data/interfaces/db/survey/load-survey-by-id-repository' 3 | import { LoadSurveyRepository } from '@data/interfaces/db/survey/load-survey-repository' 4 | import { SurveyModel } from '@domain/models/survey' 5 | import { AddSurveyParams } from '@domain/usecases/add-survey' 6 | import { ObjectId } from 'mongodb' 7 | import { MongoHelper } from '../helpers/mongo' 8 | import { QueryBuilder } from '../helpers/query-bulder' 9 | 10 | export class MongoSurveyRepository 11 | implements AddSurveyRepository, LoadSurveyRepository, LoadSurveyByIdRepository { 12 | async add(surveyData: AddSurveyParams): Promise { 13 | const surveyCollection = await MongoHelper.getCollection('surveys') 14 | await surveyCollection.insertOne(surveyData) 15 | } 16 | 17 | async loadAll(accountId: string): Promise { 18 | const surveyCollection = await MongoHelper.getCollection('surveys') 19 | const query = new QueryBuilder() 20 | .lookup({ 21 | from: 'surveyResult', 22 | foreignField: 'surveyId', 23 | localField: '_id', 24 | as: 'result' 25 | }) 26 | .project({ 27 | _id: 1, 28 | question: 1, 29 | answers: 1, 30 | date: 1, 31 | didAnswer: { 32 | $gte: [ 33 | { 34 | $size: { 35 | $filter: { 36 | input: '$result', 37 | as: 'item', 38 | cond: { $eq: ['$$item.accountId', new ObjectId(accountId)] } 39 | } 40 | } 41 | }, 42 | 1 43 | ] 44 | } 45 | }) 46 | .build() 47 | const surveys = await surveyCollection.aggregate(query).toArray() 48 | return MongoHelper.mapCollection(surveys) 49 | } 50 | 51 | async loadById(id: string): Promise { 52 | const surveyCollection = await MongoHelper.getCollection('surveys') 53 | const survey = await surveyCollection.findOne({ _id: new ObjectId(id) }) 54 | return survey && MongoHelper.map(survey) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/infra/validation/email-validator-adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { EmailValidatorAdapter } from './email-validator-adapter' 2 | import validator from 'validator' 3 | 4 | jest.mock('validator', () => ({ 5 | isEmail(): boolean { 6 | return true 7 | } 8 | })) 9 | 10 | describe('EmailValidator Adapter', () => { 11 | const sut = new EmailValidatorAdapter() 12 | 13 | test('Should return false if validator return false', () => { 14 | jest.spyOn(validator, 'isEmail').mockReturnValueOnce(false) 15 | const isValidEmail = sut.isValid('invalid_email@gmail.com') 16 | expect(isValidEmail).toBe(false) 17 | }) 18 | 19 | test('Should return true if validator return true', () => { 20 | const isValidEmail = sut.isValid('valid_email@gmail.com') 21 | expect(isValidEmail).toBe(true) 22 | }) 23 | 24 | test('Should call validator with correct email', () => { 25 | const isEmailSpy = jest.spyOn(validator, 'isEmail') 26 | sut.isValid('valid_email@gmail.com') 27 | expect(isEmailSpy).toHaveBeenCalledWith('valid_email@gmail.com') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/infra/validation/email-validator-adapter.ts: -------------------------------------------------------------------------------- 1 | import { EmailValidator } from '../../validation/interfaces/email-validator' 2 | import validator from 'validator' 3 | 4 | export class EmailValidatorAdapter implements EmailValidator { 5 | isValid(email: string): boolean { 6 | return validator.isEmail(email) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/adapters/apollo-server-resolver-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@presentation/interfaces' 2 | import { 3 | ApolloError, 4 | AuthenticationError, 5 | ForbiddenError, 6 | UserInputError 7 | } from 'apollo-server-errors' 8 | 9 | export const adaptResolver = async (controller: Controller, args?: any): Promise => { 10 | const response = await controller.handle({ ...(args || {}) }) 11 | switch (response.statusCode) { 12 | case 200: 13 | return response.body 14 | case 204: 15 | return response.body 16 | case 400: 17 | throw new UserInputError(response.body.message) 18 | case 401: 19 | throw new AuthenticationError(response.body.message) 20 | case 403: 21 | throw new ForbiddenError(response.body.message) 22 | default: 23 | throw new ApolloError(response.body.message) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/adapters/express-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from '@presentation/interfaces/middleware' 2 | import { Request, Response, NextFunction } from 'express' 3 | 4 | export const adaptMiddleware = (middleware: Middleware) => { 5 | return async (req: Request, res: Response, next: NextFunction) => { 6 | const request = { accessToken: req.headers?.['access-token'], ...(req.headers || {}) } 7 | const httpResponse = await middleware.handle(request) 8 | if (httpResponse.statusCode === 200) { 9 | Object.assign(req, httpResponse.body) 10 | next() 11 | } else { 12 | res.status(httpResponse.statusCode).json({ error: httpResponse.body.message }) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/adapters/express-routes.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@presentation/interfaces' 2 | import { Request, Response } from 'express' 3 | 4 | export const adaptRoutes = (controller: Controller) => { 5 | return async (req: Request, res: Response) => { 6 | const request = { 7 | ...(req.body || {}), 8 | ...(req.params || {}), 9 | accountId: req.accountId 10 | } 11 | const httpResponse = await controller.handle(request) 12 | if (httpResponse.statusCode >= 200 && httpResponse.statusCode <= 299) { 13 | res.status(httpResponse.statusCode).json(httpResponse.body) 14 | } else { 15 | res.status(httpResponse.statusCode).json({ error: httpResponse.body.message }) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/config/apollo-server.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | import { ApolloServer } from 'apollo-server-express' 3 | import resolvers from '../graphql/resolvers' 4 | import typeDefs from '../graphql/type-defs' 5 | import { GraphQLError } from 'graphql' 6 | import schemaDirectives from '../graphql/directives/index' 7 | 8 | const handleErrors = (response: any, errors: readonly GraphQLError[]): void => { 9 | errors?.forEach(error => { 10 | response.data = undefined 11 | if (validateError(error, 'AuthenticationError')) response.http.status = 401 12 | else if (validateError(error, 'ForbiddenError')) response.http.status = 403 13 | else if (validateError(error, 'UserInputError')) response.http.status = 400 14 | else response.http.status = 500 15 | }) 16 | } 17 | 18 | const validateError = (error: GraphQLError, errorName: string): boolean => { 19 | return [error.name, error.originalError?.name].some(name => name === errorName) 20 | } 21 | 22 | export default (app: Express) => { 23 | const server = new ApolloServer({ 24 | resolvers, 25 | typeDefs, 26 | schemaDirectives, 27 | context: ({ req }) => ({ req }), 28 | plugins: [ 29 | { 30 | requestDidStart: () => ({ 31 | willSendResponse: ({ response, errors }) => handleErrors(response, errors) 32 | }) 33 | } 34 | ] 35 | }) 36 | server.applyMiddleware({ app }) 37 | } 38 | -------------------------------------------------------------------------------- /src/main/config/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import Middlewares from '../config/middlewares' 3 | import Routes from '../config/routes' 4 | import Swagger from './swagger' 5 | import StaticFiles from './static-files' 6 | import ApolloServer from './apollo-server' 7 | // import ExceptRoute from './except-route' 8 | 9 | const app = express() 10 | // ExceptRoute(app) 11 | StaticFiles(app) 12 | ApolloServer(app) 13 | Swagger(app) 14 | Middlewares(app) 15 | Routes(app) 16 | 17 | export default app 18 | -------------------------------------------------------------------------------- /src/main/config/env.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | export default { 4 | mongoUrl: 5 | process.env.MONGO_URL || 'mongodb://localhost:27017/clean-typescript-api', 6 | port: process.env.PORT || 8080, 7 | jwtSecret: process.env.JWT_SECRET_KEY || 'jw3R4fdaF74Fd7dfH' 8 | } 9 | -------------------------------------------------------------------------------- /src/main/config/except-route.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | 3 | export default (app: Express) => { 4 | app.use((req, res, next) => { 5 | res.status(404).json({ 6 | message: 7 | "Sorry, this route doesn't exists. See documentation at https://clean-typescript-api.herokuapp.com/api/docs" 8 | }) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/config/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | 3 | import { bodyParser, contentType, cors } from '../middlewares/index' 4 | export default (app: Express): void => { 5 | app.use(bodyParser) 6 | app.use(cors) 7 | app.use(contentType) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/config/routes.ts: -------------------------------------------------------------------------------- 1 | import { Express, Router } from 'express' 2 | import { readdirSync } from 'fs' 3 | import path from 'path' 4 | 5 | export default (app: Express): void => { 6 | const router = Router() 7 | app.use('/api', router) 8 | readdirSync(path.resolve(__dirname, '..', 'routes')).map(async file => { 9 | if (!file.includes('.test.') && !file.endsWith('.map')) { 10 | ;(await import(`../routes/${file}`)).default(router) 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/config/static-files.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express' 2 | import { resolve } from 'path' 3 | 4 | export default (app: Express): void => { 5 | app.use('/api/static', express.static(resolve(__dirname, '..', '..', 'static'))) 6 | } 7 | -------------------------------------------------------------------------------- /src/main/config/swagger.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | import { serve, setup } from 'swagger-ui-express' 3 | import swaggerConfig from '../docs/index' 4 | import { noCache } from '@main/middlewares/no-cache' 5 | 6 | export default (app: Express) => { 7 | app.use('/api/docs', noCache, serve, setup(swaggerConfig)) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/decorators/log-controller-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { LogErrorRepository } from '../../data/interfaces/db/log/log-error-repository' 2 | import { fakeAccountModel, fakeAccountParams } from '../../domain/mocks/mock-account' 3 | import { ok, serverError } from '../../presentation/helpers/http/http' 4 | import { Controller, HttpResponse } from '../../presentation/interfaces' 5 | import { LogControllerDecorator } from './log-controller-decorator' 6 | 7 | const fakeRequest = fakeAccountParams 8 | 9 | const fakeError = new Error() 10 | fakeError.stack = 'error_stack' 11 | const fakeServerError = serverError(fakeError) 12 | 13 | class MockLogErrorRepository implements LogErrorRepository { 14 | async logError(stackError: string): Promise { 15 | return new Promise(resolve => resolve()) 16 | } 17 | } 18 | 19 | class MockController implements Controller { 20 | async handle(httpRequest): Promise { 21 | return new Promise(resolve => resolve(ok(fakeAccountModel))) 22 | } 23 | } 24 | 25 | describe('LogController Decorator', () => { 26 | const mockController = new MockController() as jest.Mocked 27 | const mockLogErrorRepository = new MockLogErrorRepository() as jest.Mocked 28 | const sut = new LogControllerDecorator(mockController, mockLogErrorRepository) 29 | 30 | test('Should call controller handle', async () => { 31 | const handleSpy = jest.spyOn(mockController, 'handle') 32 | await sut.handle(fakeRequest) 33 | expect(handleSpy).toHaveBeenCalledWith(fakeRequest) 34 | }) 35 | 36 | test('Should return the same result of the controller', async () => { 37 | const httpResponse = await sut.handle(fakeRequest) 38 | expect(httpResponse).toEqual(ok(fakeAccountModel)) 39 | }) 40 | 41 | test('Should call LogError with correct error if controller returns a server error', async () => { 42 | const logSpy = jest.spyOn(mockLogErrorRepository, 'logError') 43 | mockController.handle.mockResolvedValueOnce(fakeServerError) 44 | await sut.handle(fakeRequest) 45 | expect(logSpy).toHaveBeenCalledWith('error_stack') 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/main/decorators/log-controller-decorator.ts: -------------------------------------------------------------------------------- 1 | import { Controller, HttpResponse } from '@presentation/interfaces' 2 | import { LogErrorRepository } from '@data/interfaces/db/log/log-error-repository' 3 | 4 | export class LogControllerDecorator implements Controller { 5 | constructor( 6 | private readonly controller: Controller, 7 | private readonly logErrorRepository: LogErrorRepository 8 | ) {} 9 | 10 | async handle(httpRequest: any): Promise { 11 | const httpResponse = await this.controller.handle(httpRequest) 12 | if (httpResponse.statusCode === 500) { 13 | await this.logErrorRepository.logError(httpResponse.body.stack) 14 | } 15 | return httpResponse 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/docs/components/bad-request.ts: -------------------------------------------------------------------------------- 1 | export const badRequest = { 2 | description: 'Invalid Request', 3 | content: { 'application/json': { schema: { $ref: '#schemas/error' } } } 4 | } 5 | -------------------------------------------------------------------------------- /src/main/docs/components/forbidden.ts: -------------------------------------------------------------------------------- 1 | export const forbidden = { 2 | description: 'Access Denied', 3 | content: { 'application/json': { schema: { $ref: '#schemas/error' } } } 4 | } 5 | -------------------------------------------------------------------------------- /src/main/docs/components/not-found.ts: -------------------------------------------------------------------------------- 1 | export const notFound = { 2 | description: 'API not found' 3 | } 4 | -------------------------------------------------------------------------------- /src/main/docs/components/server-error.ts: -------------------------------------------------------------------------------- 1 | export const serverError = { 2 | description: 'Internal Server Error', 3 | content: { 'application/json': { schema: { $ref: '#schemas/error' } } } 4 | } 5 | -------------------------------------------------------------------------------- /src/main/docs/components/unauthorized.ts: -------------------------------------------------------------------------------- 1 | export const unauthorized = { 2 | description: 'Invalid Credentials', 3 | content: { 'application/json': { schema: { $ref: '#schemas/error' } } } 4 | } 5 | -------------------------------------------------------------------------------- /src/main/docs/index.ts: -------------------------------------------------------------------------------- 1 | import { badRequest } from './components/bad-request' 2 | import { forbidden } from './components/forbidden' 3 | import { notFound } from './components/not-found' 4 | import { serverError } from './components/server-error' 5 | import { unauthorized } from './components/unauthorized' 6 | import { loginPath } from './paths/login-path' 7 | import { signUpPath } from './paths/signup-path' 8 | import { surveyResultPath } from './paths/survey-result-path' 9 | import { surveysPath } from './paths/surveys-path' 10 | import { accountSchema } from './schemas/account-schema' 11 | import { addSurveySchema } from './schemas/add-survey-schema' 12 | import { apiKeyAuthSchema } from './schemas/api-key-auth-schema' 13 | import { errorSchema } from './schemas/error-schema' 14 | import { loginSchema } from './schemas/login-schema' 15 | import { saveSurveySchema } from './schemas/save-survey-schema' 16 | import { signUpSchema } from './schemas/signup-schema' 17 | import { surveyAnswerSchema } from './schemas/survey-answer-schema' 18 | import { surveyResultAnswerSchema } from './schemas/survey-result-answer-schema' 19 | import { surveyResultSchema } from './schemas/survey-result-schema' 20 | import { surveySchema } from './schemas/survey-schema' 21 | import { surveysSchema } from './schemas/surveys-schema' 22 | 23 | export default { 24 | openapi: '3.0.0', 25 | info: { 26 | title: 'Clean Typescript API', 27 | description: 'An API made with Node.JS, Typescript and MongoDB', 28 | version: '1.5.0' 29 | }, 30 | license: { 31 | name: 'MIT', 32 | url: 'https://github.com/gabriellopes00/clean-typescript-api/blob/main/LICENSE.md' 33 | }, 34 | servers: [{ url: '/api' }], 35 | tags: [{ name: 'Login' }, { name: 'Surveys' }], 36 | paths: { 37 | '/login': loginPath, 38 | '/signup': signUpPath, 39 | '/surveys': surveysPath, 40 | '/surveys/{surveyId}/results': surveyResultPath 41 | }, 42 | schemas: { 43 | account: accountSchema, 44 | login: loginSchema, 45 | error: errorSchema, 46 | survey: surveySchema, 47 | surveys: surveysSchema, 48 | surveyAnswer: surveyAnswerSchema, 49 | signup: signUpSchema, 50 | addSurvey: addSurveySchema, 51 | saveSurvey: saveSurveySchema, 52 | saveResultSurvey: surveyResultSchema, 53 | surveyResult: surveyResultSchema, 54 | surveyResultAnswer: surveyResultAnswerSchema 55 | }, 56 | components: { 57 | securitySchemes: { 58 | apiKeyAuth: apiKeyAuthSchema 59 | }, 60 | badRequest: badRequest, 61 | serverError: serverError, 62 | unauthorized: unauthorized, 63 | notFound: notFound, 64 | forbidden: forbidden 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/docs/paths/login-path.ts: -------------------------------------------------------------------------------- 1 | export const loginPath = { 2 | post: { 3 | tags: ['Login'], 4 | summary: 'User authentication API route', 5 | requestBody: { 6 | description: 7 | 'This path must receive an email and a password of the user which is trying to log in.', 8 | content: { 'application/json': { schema: { $ref: '#/schemas/login' } } } 9 | }, 10 | responses: { 11 | 200: { 12 | description: 'Success', 13 | content: { 'application/json': { schema: { $ref: '#schemas/account' } } } 14 | }, 15 | 400: { $ref: '#/components/badRequest' }, 16 | 401: { $ref: '#/components/unauthorized' }, 17 | 404: { $ref: '#/components/notFound' }, 18 | 500: { $ref: '#/components/serverError' } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/docs/paths/signup-path.ts: -------------------------------------------------------------------------------- 1 | export const signUpPath = { 2 | post: { 3 | tags: ['Login'], 4 | summary: 'User account creation API', 5 | requestBody: { 6 | description: 7 | 'This path must receive a name, email, password and a password confirmation, of the user which is trying to create a new account.', 8 | content: { 'application/json': { schema: { $ref: '#/schemas/signup' } } } 9 | }, 10 | responses: { 11 | 200: { 12 | description: "Success. Already return the user's login token", 13 | content: { 'application/json': { schema: { $ref: '#schemas/account' } } } 14 | }, 15 | 400: { $ref: '#/components/badRequest' }, 16 | 403: { $ref: '#/components/forbidden' }, 17 | 404: { $ref: '#/components/notFound' }, 18 | 500: { $ref: '#/components/serverError' } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/docs/paths/survey-result-path.ts: -------------------------------------------------------------------------------- 1 | export const surveyResultPath = { 2 | put: { 3 | security: [{ apiKeyAuth: [] }], 4 | tags: ['Surveys'], 5 | summary: 'Surveys response creation API', 6 | requestBody: { 7 | content: { 'application/json': { schema: { $ref: '#/schemas/saveSurvey' } } } 8 | }, 9 | parameters: [{ in: 'path', name: 'surveyId', required: true, schema: { type: 'string' } }], 10 | responses: { 11 | 200: { 12 | description: 'Success', 13 | content: { 'application/json': { schema: { $ref: '#schemas/surveyResult' } } } 14 | }, 15 | 403: { $ref: '#/components/forbidden' }, 16 | 404: { $ref: '#/components/notFound' }, 17 | 500: { $ref: '#/components/serverError' } 18 | } 19 | }, 20 | get: { 21 | security: [{ apiKeyAuth: [] }], 22 | tags: ['Surveys'], 23 | summary: 'Surveys result query API', 24 | parameters: [{ in: 'path', name: 'surveyId', required: true, schema: { type: 'string' } }], 25 | responses: { 26 | 200: { 27 | description: 'Success', 28 | content: { 'application/json': { schema: { $ref: '#schemas/surveyResult' } } } 29 | }, 30 | 403: { $ref: '#/components/forbidden' }, 31 | 404: { $ref: '#/components/notFound' }, 32 | 500: { $ref: '#/components/serverError' } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/docs/paths/surveys-path.ts: -------------------------------------------------------------------------------- 1 | export const surveysPath = { 2 | get: { 3 | security: [{ apiKeyAuth: [] }], 4 | tags: ['Surveys'], 5 | summary: 'Surveys list API', 6 | responses: { 7 | 200: { 8 | description: 'Success', 9 | content: { 'application/json': { schema: { $ref: '#schemas/surveys' } } } 10 | }, 11 | 403: { $ref: '#/components/forbidden' }, 12 | 404: { $ref: '#/components/notFound' }, 13 | 500: { $ref: '#/components/serverError' } 14 | } 15 | }, 16 | post: { 17 | security: [{ apiKeyAuth: [] }], 18 | tags: ['Surveys'], 19 | summary: 'Surveys creation API', 20 | requestBody: { 21 | content: { 'application/json': { schema: { $ref: '#/schemas/addSurvey' } } } 22 | }, 23 | responses: { 24 | 204: { description: 'Success' }, 25 | 403: { $ref: '#/components/forbidden' }, 26 | 404: { $ref: '#/components/notFound' }, 27 | 500: { $ref: '#/components/serverError' } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/docs/schemas/account-schema.ts: -------------------------------------------------------------------------------- 1 | export const accountSchema = { 2 | type: 'object', 3 | properties: { accessToken: { type: 'string' }, name: { type: 'string' } }, 4 | required: ['accessToken', 'name'] 5 | } 6 | -------------------------------------------------------------------------------- /src/main/docs/schemas/add-survey-schema.ts: -------------------------------------------------------------------------------- 1 | export const addSurveySchema = { 2 | type: 'object', 3 | properties: { 4 | question: { type: 'string' }, 5 | answer: { type: 'array', items: { $ref: '#/schemas/surveyAnswer' } } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/docs/schemas/api-key-auth-schema.ts: -------------------------------------------------------------------------------- 1 | export const apiKeyAuthSchema = { 2 | type: 'apiKey', 3 | in: 'header', 4 | name: 'access-token' 5 | } 6 | -------------------------------------------------------------------------------- /src/main/docs/schemas/error-schema.ts: -------------------------------------------------------------------------------- 1 | export const errorSchema = { 2 | type: 'object', 3 | properties: { error: { type: 'string' } } 4 | } 5 | -------------------------------------------------------------------------------- /src/main/docs/schemas/login-schema.ts: -------------------------------------------------------------------------------- 1 | export const loginSchema = { 2 | type: 'object', 3 | properties: { email: { type: 'string' }, password: { type: 'string' } }, 4 | required: ['email', 'password'] 5 | } 6 | -------------------------------------------------------------------------------- /src/main/docs/schemas/save-survey-schema.ts: -------------------------------------------------------------------------------- 1 | export const saveSurveySchema = { 2 | type: 'object', 3 | properties: { answer: { type: 'string' } } 4 | } 5 | -------------------------------------------------------------------------------- /src/main/docs/schemas/signup-schema.ts: -------------------------------------------------------------------------------- 1 | export const signUpSchema = { 2 | type: 'object', 3 | properties: { 4 | name: { type: 'string' }, 5 | email: { type: 'string' }, 6 | password: { type: 'string' }, 7 | passwordConfirmation: { type: 'string' } 8 | }, 9 | required: ['name', 'email', 'password', 'passwordConfirmation'] 10 | } 11 | -------------------------------------------------------------------------------- /src/main/docs/schemas/survey-answer-schema.ts: -------------------------------------------------------------------------------- 1 | export const surveyAnswerSchema = { 2 | type: 'object', 3 | properties: { 4 | image: { type: 'string' }, 5 | answer: { type: 'string' } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/docs/schemas/survey-result-answer-schema.ts: -------------------------------------------------------------------------------- 1 | export const surveyResultAnswerSchema = { 2 | type: 'object', 3 | properties: { 4 | image: { type: 'string' }, 5 | answer: { type: 'string' }, 6 | count: { type: 'integer' }, 7 | percent: { type: 'number' }, 8 | isCurrentAccountResponse: { type: 'boolean' } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/docs/schemas/survey-result-schema.ts: -------------------------------------------------------------------------------- 1 | export const surveyResultSchema = { 2 | type: 'object', 3 | properties: { 4 | surveyId: { type: 'string' }, 5 | question: { type: 'string' }, 6 | answers: { type: 'array', items: { $ref: '#/schemas/surveyResultAnswer' } }, 7 | date: { type: 'string' } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/docs/schemas/survey-schema.ts: -------------------------------------------------------------------------------- 1 | export const surveySchema = { 2 | type: 'object', 3 | properties: { 4 | id: { type: 'string' }, 5 | question: { type: 'string' }, 6 | date: { type: 'string' }, 7 | answer: { type: 'array', items: { $ref: '#/schemas/surveyAnswer' } }, 8 | didAnswer: { type: 'boolean' } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/docs/schemas/surveys-schema.ts: -------------------------------------------------------------------------------- 1 | export const surveysSchema = { 2 | type: 'array', 3 | items: { $ref: '#/schemas/survey' } 4 | } 5 | -------------------------------------------------------------------------------- /src/main/factories/add-survey/add-survey-controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@presentation/interfaces/controller' 2 | import { AddSurveyController } from '@presentation/controllers/survey/add-survey/add-survey' 3 | import { makeLogController } from '../decorators/log-controller-factory' 4 | import { makeAddSurveyValidation } from './add-survey-validations-factory' 5 | import { makeDbAddSurvey } from '../usecases/db-add-survey-factory' 6 | 7 | export const makeAddSurveyController = (): Controller => { 8 | const controller = new AddSurveyController( 9 | makeAddSurveyValidation(), 10 | makeDbAddSurvey() 11 | ) 12 | return makeLogController(controller) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/factories/add-survey/add-survey-validations-factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '../../../validation/validator/validation-composite' 2 | import { RequiredFieldValidation } from '../../../validation/validator/required-fields-validation' 3 | import { Validation } from '../../../presentation/interfaces/validation' 4 | import { makeAddSurveyController } from './add-survey-controller-factory' 5 | 6 | jest.mock('../../../validation/validator/validation-composite') 7 | 8 | describe('AddSurveyValidation factory', () => { 9 | test('Should call ValidationComposite with correct validations', () => { 10 | makeAddSurveyController() 11 | const validations: Validation[] = [] 12 | for (const field of ['question', 'answers']) { 13 | validations.push(new RequiredFieldValidation(field)) 14 | } 15 | 16 | expect(ValidationComposite).toHaveBeenCalledWith(validations) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/main/factories/add-survey/add-survey-validations-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '../../../validation/validator/validation-composite' 2 | import { RequiredFieldValidation } from '../../../validation/validator/required-fields-validation' 3 | import { Validation } from '@presentation/interfaces/validation' 4 | 5 | export const makeAddSurveyValidation = (): ValidationComposite => { 6 | const validations: Validation[] = [] 7 | for (const field of ['question', 'answers']) { 8 | validations.push(new RequiredFieldValidation(field)) 9 | } 10 | return new ValidationComposite(validations) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/factories/decorators/log-controller-factory.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@presentation/interfaces/controller' 2 | import { MongoLogRepository } from '@infra/db/mongodb/log/mongo-log-repository' 3 | import { LogControllerDecorator } from '../../decorators/log-controller-decorator' 4 | 5 | export const makeLogController = (controller: Controller): Controller => { 6 | const mongoLogRepository = new MongoLogRepository() 7 | 8 | return new LogControllerDecorator(controller, mongoLogRepository) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/factories/load-survey-result/load-survey-result-controller.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyResultController } from '@presentation/controllers/survey-result/load-survey-result-controller' 2 | import { Controller } from '../../../presentation/interfaces/controller' 3 | import { makeLogController } from '../decorators/log-controller-factory' 4 | import { makeDbLoadSurveyById } from '../usecases/db-load-survey-by-id' 5 | import { makeDbLoadSurveyResult } from '../usecases/db-load-survey-result' 6 | 7 | export const makeLoadSurveyResultController = (): Controller => { 8 | const controller = new LoadSurveyResultController( 9 | makeDbLoadSurveyById(), 10 | makeDbLoadSurveyResult() 11 | ) 12 | return makeLogController(controller) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/factories/load-survey/load-survey-controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '../../../presentation/interfaces/controller' 2 | import { LoadSurveyController } from '../../../presentation/controllers/survey/load-survey/load-survey-controller' 3 | import { makeLogController } from '../decorators/log-controller-factory' 4 | import { makeDbLoadSurvey } from '../usecases/db-load-survey' 5 | 6 | export const makeLoadSurveysController = (): Controller => { 7 | const controller = new LoadSurveyController(makeDbLoadSurvey()) 8 | return makeLogController(controller) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/factories/login/login-factory.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@presentation/interfaces/controller' 2 | import { LoginController } from '@presentation/controllers/login/login-controller' 3 | import { makeLoginValidation } from '../../factories/login/login-validation-factory' 4 | import { makeDbAuthentication } from '../usecases/db-authentication-factory' 5 | import { makeLogController } from '../decorators/log-controller-factory' 6 | 7 | export const makeLoginController = (): Controller => { 8 | const controller = new LoginController( 9 | makeLoginValidation(), 10 | makeDbAuthentication() 11 | ) 12 | 13 | return makeLogController(controller) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/factories/login/login-validation-factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '../../../validation/validator/validation-composite' 2 | import { RequiredFieldValidation } from '../../../validation/validator/required-fields-validation' 3 | import { Validation } from '../../../presentation/interfaces/validation' 4 | import { EmailValidation } from '../../../validation/validator/email-validation' 5 | import { EmailValidator } from '../../../validation/interfaces/email-validator' 6 | import { makeLoginValidation } from './login-validation-factory' 7 | 8 | jest.mock('../../../validation/validator/validation-composite') 9 | 10 | class MockEmailValidation implements EmailValidator { 11 | isValid(email: string): boolean { 12 | return true 13 | } 14 | } 15 | 16 | describe('LoginValidation factory', () => { 17 | const mockEmailValidation = new MockEmailValidation() 18 | 19 | test('Should call ValidationComposite with correct validations', () => { 20 | makeLoginValidation() 21 | const validations: Validation[] = [] 22 | for (const field of ['email', 'password']) { 23 | validations.push(new RequiredFieldValidation(field)) 24 | } 25 | validations.push(new EmailValidation('email', mockEmailValidation)) 26 | 27 | expect(ValidationComposite).toHaveBeenCalledWith(validations) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/main/factories/login/login-validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '../../../validation/validator/validation-composite' 2 | import { RequiredFieldValidation } from '../../../validation/validator/required-fields-validation' 3 | import { Validation } from '@presentation/interfaces/validation' 4 | import { EmailValidation } from '../../../validation/validator/email-validation' 5 | import { EmailValidatorAdapter } from '@infra/validation/email-validator-adapter' 6 | 7 | export const makeLoginValidation = (): ValidationComposite => { 8 | const validations: Validation[] = [] 9 | for (const field of ['email', 'password']) { 10 | validations.push(new RequiredFieldValidation(field)) 11 | } 12 | validations.push(new EmailValidation('email', new EmailValidatorAdapter())) 13 | return new ValidationComposite(validations) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/factories/middlewares/auth-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from '@presentation/interfaces/middleware' 2 | import { AuthMiddleware } from '@presentation/middlewares/auth-middleware' 3 | import { makeDbLoadAccountByToken } from '../usecases/db-load-account-by-token' 4 | 5 | export const makeAuthMiddleware = (role?: string): Middleware => { 6 | return new AuthMiddleware(makeDbLoadAccountByToken(), role) 7 | } 8 | -------------------------------------------------------------------------------- /src/main/factories/save-survey-result/save-survey-result-controller.ts: -------------------------------------------------------------------------------- 1 | import { SaveSurveyResultController } from '@presentation/controllers/survey-result/save-survey-result-controller' 2 | import { Controller } from '../../../presentation/interfaces/controller' 3 | import { makeLogController } from '../decorators/log-controller-factory' 4 | import { makeDbLoadSurveyById } from '../usecases/db-load-survey-by-id' 5 | import { makeDbSaveSurveyResult } from '../usecases/db-save-survey-result-factory' 6 | 7 | export const makeSaveSurveyResultController = (): Controller => { 8 | const controller = new SaveSurveyResultController( 9 | makeDbLoadSurveyById(), 10 | makeDbSaveSurveyResult() 11 | ) 12 | return makeLogController(controller) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/factories/signup/signup-factory.ts: -------------------------------------------------------------------------------- 1 | import { SignUpController } from '@presentation/controllers/signup/signup-controller' 2 | import { Controller } from '@presentation/interfaces' 3 | import { makeSignUpValidation } from './signup-validation-factory' 4 | import { makeDbAuthentication } from '../usecases/db-authentication-factory' 5 | import { makeDbAddAccount } from '../usecases/db-add-account-factory' 6 | import { makeLogController } from '../decorators/log-controller-factory' 7 | 8 | export const makeSignUpController = (): Controller => { 9 | const controller = new SignUpController( 10 | makeSignUpValidation(), 11 | makeDbAddAccount(), 12 | makeDbAuthentication() 13 | ) 14 | return makeLogController(controller) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/factories/signup/signup-validation-factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '../../../validation/validator/validation-composite' 2 | import { RequiredFieldValidation } from '../../../validation/validator/required-fields-validation' 3 | import { Validation } from '../../../presentation/interfaces/validation' 4 | import { CompareFieldsValidation } from '../../../validation/validator/compare-fields-validation' 5 | import { EmailValidation } from '../../../validation/validator/email-validation' 6 | import { EmailValidator } from '../../../validation/interfaces/email-validator' 7 | import { makeSignUpValidation } from './signup-validation-factory' 8 | 9 | jest.mock('../../../validation/validator/validation-composite') 10 | 11 | class MockEmailValidation implements EmailValidator { 12 | isValid(email: string): boolean { 13 | return true 14 | } 15 | } 16 | 17 | describe('SignUpValidation factory', () => { 18 | const mockEmailValidation = new MockEmailValidation() 19 | 20 | test('Should call ValidationComposite with correct validations', () => { 21 | makeSignUpValidation() 22 | const validations: Validation[] = [] 23 | for (const field of ['name', 'email', 'password', 'passwordConfirmation']) { 24 | validations.push(new RequiredFieldValidation(field)) 25 | } 26 | 27 | validations.push(new CompareFieldsValidation('password', 'passwordConfirmation')) 28 | validations.push(new EmailValidation('email', mockEmailValidation)) 29 | expect(ValidationComposite).toHaveBeenCalledWith(validations) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/main/factories/signup/signup-validation-factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidationComposite } from '../../../validation/validator/validation-composite' 2 | import { RequiredFieldValidation } from '../../../validation/validator/required-fields-validation' 3 | import { Validation } from '@presentation/interfaces/validation' 4 | import { CompareFieldsValidation } from '../../../validation/validator/compare-fields-validation' 5 | import { EmailValidation } from '../../../validation/validator/email-validation' 6 | import { EmailValidatorAdapter } from '@infra/validation/email-validator-adapter' 7 | 8 | export const makeSignUpValidation = (): ValidationComposite => { 9 | const validations: Validation[] = [] 10 | for (const field of ['name', 'email', 'password', 'passwordConfirmation']) { 11 | validations.push(new RequiredFieldValidation(field)) 12 | } 13 | validations.push( 14 | new CompareFieldsValidation('password', 'passwordConfirmation') 15 | ) 16 | validations.push(new EmailValidation('email', new EmailValidatorAdapter())) 17 | return new ValidationComposite(validations) 18 | } 19 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-add-account-factory.ts: -------------------------------------------------------------------------------- 1 | import { DbAddAccount } from '@data/usecases/add-account/db-add-account' 2 | import { BcryptAdapter } from '@infra/cryptography/bcrypt-adapter/bcrypt-adapter' 3 | import { MongoAccountRepository } from '@infra/db/mongodb/account/mongo-account-repository' 4 | export const makeDbAddAccount = (): DbAddAccount => { 5 | const salt = 12 6 | const bcryptAdapter = new BcryptAdapter(salt) 7 | 8 | const mongoAccountRepository = new MongoAccountRepository() 9 | return new DbAddAccount( 10 | bcryptAdapter, 11 | mongoAccountRepository, 12 | mongoAccountRepository 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-add-survey-factory.ts: -------------------------------------------------------------------------------- 1 | import { DbAddSurvey } from '@data/usecases/add-survey/db-add-survey' 2 | import { MongoSurveyRepository } from '@infra/db/mongodb/survey/mongo-survey-repository' 3 | 4 | export const makeDbAddSurvey = (): DbAddSurvey => { 5 | const mongoSurveyRepository = new MongoSurveyRepository() 6 | return new DbAddSurvey(mongoSurveyRepository) 7 | } 8 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-authentication-factory.ts: -------------------------------------------------------------------------------- 1 | import env from '../../config/env' 2 | import { MongoAccountRepository } from '@infra/db/mongodb/account/mongo-account-repository' 3 | import { BcryptAdapter } from '@infra/cryptography/bcrypt-adapter/bcrypt-adapter' 4 | import { JwtAdapter } from '@infra/cryptography/jwt-adapter/jwt-adapter' 5 | import { DbAuthentication } from '@data/usecases/authentication/db-authentication' 6 | import { Authenticator } from '@domain/usecases/authentication' 7 | 8 | export const makeDbAuthentication = (): Authenticator => { 9 | const salt = 12 10 | const bcryptAdapter = new BcryptAdapter(salt) 11 | const jwtAdapter = new JwtAdapter(env.jwtSecret) 12 | const mongoAccountRepository = new MongoAccountRepository() 13 | 14 | return new DbAuthentication( 15 | mongoAccountRepository, 16 | bcryptAdapter, 17 | jwtAdapter, 18 | mongoAccountRepository 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-load-account-by-token.ts: -------------------------------------------------------------------------------- 1 | import { LoadAccountByToken } from '@domain/usecases/load-account-by-token' 2 | import { DbLoadAccountByToken } from '@data/usecases/load-account/db-load-account-by-token' 3 | import { MongoAccountRepository } from '@infra/db/mongodb/account/mongo-account-repository' 4 | import { JwtAdapter } from '@infra/cryptography/jwt-adapter/jwt-adapter' 5 | import env from '../../config/env' 6 | 7 | export const makeDbLoadAccountByToken = (): LoadAccountByToken => { 8 | const mongoAccountRepository = new MongoAccountRepository() 9 | const decrypter = new JwtAdapter(env.jwtSecret) 10 | return new DbLoadAccountByToken(decrypter, mongoAccountRepository) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-load-survey-by-id.ts: -------------------------------------------------------------------------------- 1 | import { DbLoadSurveyById } from '@data/usecases/load-survey-by-id/load-survey-by-id' 2 | import { LoadSurveyById } from '@domain/usecases/load-survey' 3 | import { MongoSurveyRepository } from '@infra/db/mongodb/survey/mongo-survey-repository' 4 | 5 | export const makeDbLoadSurveyById = (): LoadSurveyById => { 6 | const mongoSurveyRepository = new MongoSurveyRepository() 7 | return new DbLoadSurveyById(mongoSurveyRepository) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-load-survey-result.ts: -------------------------------------------------------------------------------- 1 | import { DbLoadSurveyResults } from '@data/usecases/load-survey-result/db-load-survey-result' 2 | import { LoadSurveyResult } from '@domain/usecases/load-survey-results' 3 | import { MongoSurveyResultRepository } from '@infra/db/mongodb/survey-result/mongo-survey-results-repository' 4 | import { MongoSurveyRepository } from '@infra/db/mongodb/survey/mongo-survey-repository' 5 | 6 | export const makeDbLoadSurveyResult = (): LoadSurveyResult => { 7 | const mongoSurveyResultRepository = new MongoSurveyResultRepository() 8 | const mongoSurveyRepository = new MongoSurveyRepository() 9 | return new DbLoadSurveyResults(mongoSurveyResultRepository, mongoSurveyRepository) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-load-survey.ts: -------------------------------------------------------------------------------- 1 | import { DbLoadSurvey } from '@data/usecases/load-survey/db-load-survey' 2 | import { LoadSurvey } from '@domain/usecases/load-survey' 3 | import { MongoSurveyRepository } from '@infra/db/mongodb/survey/mongo-survey-repository' 4 | 5 | export const makeDbLoadSurvey = (): LoadSurvey => { 6 | const mongoSurveyRepository = new MongoSurveyRepository() 7 | return new DbLoadSurvey(mongoSurveyRepository) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/factories/usecases/db-save-survey-result-factory.ts: -------------------------------------------------------------------------------- 1 | import { DbSaveSurveyResults } from '@data/usecases/save-survey-result/db-save-survey-result' 2 | import { SaveSurveyResults } from '@domain/usecases/save-survey-results' 3 | import { MongoSurveyResultRepository } from '@infra/db/mongodb/survey-result/mongo-survey-results-repository' 4 | 5 | export const makeDbSaveSurveyResult = (): SaveSurveyResults => { 6 | const mongoSurveyResultRepository = new MongoSurveyResultRepository() 7 | return new DbSaveSurveyResults(mongoSurveyResultRepository, mongoSurveyResultRepository) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/graphql/__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-express' 2 | import resolvers from '../resolvers' 3 | import typeDefs from '../type-defs' 4 | import schemaDirectives from '../directives' 5 | 6 | export const makeApolloServer = (): ApolloServer => 7 | new ApolloServer({ 8 | resolvers, 9 | typeDefs, 10 | schemaDirectives 11 | }) 12 | 13 | describe('', () => { 14 | test('', () => { 15 | expect(1).toBe(1) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/main/graphql/__tests__/login.test.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express' 2 | import { createTestClient } from 'apollo-server-integration-testing' 3 | import { hash } from 'bcrypt' 4 | import { Collection } from 'mongodb' 5 | import { fakeAccountParams } from '../../../domain/mocks/mock-account' 6 | import { MongoHelper } from '../../../infra/db/mongodb/helpers' 7 | import { makeApolloServer } from './helpers' 8 | 9 | describe('Login GraphQL', () => { 10 | let accountCollection: Collection 11 | const apolloServer = makeApolloServer() 12 | 13 | beforeAll(async () => await MongoHelper.connect(process.env.MONGO_URL)) 14 | afterAll(async () => await MongoHelper.disconnect()) 15 | beforeEach(async () => { 16 | accountCollection = await MongoHelper.getCollection('accounts') 17 | await accountCollection.deleteMany({}) 18 | }) 19 | 20 | describe('Login Query', () => { 21 | const loginQuery = gql` 22 | query login($email: String!, $password: String!) { 23 | login(email: $email, password: $password) { 24 | accessToken 25 | name 26 | } 27 | } 28 | ` 29 | test('Should return an account on valid credentials', async () => { 30 | const hashedPassword = await hash(fakeAccountParams.password, 12) 31 | await accountCollection.insertOne({ ...fakeAccountParams, password: hashedPassword }) 32 | 33 | const { query } = createTestClient({ apolloServer }) 34 | const response: any = await query(loginQuery, { 35 | variables: { 36 | email: fakeAccountParams.email, 37 | password: fakeAccountParams.password 38 | } 39 | }) 40 | expect(response.data.login.accessToken).toBeTruthy() 41 | }) 42 | 43 | test('Should return an unauthorized on invalid credentials', async () => { 44 | const { query } = createTestClient({ apolloServer }) 45 | const response: any = await query(loginQuery, { 46 | variables: { 47 | email: fakeAccountParams.email, 48 | password: fakeAccountParams.password 49 | } 50 | }) 51 | expect(response.data).toBeFalsy() 52 | expect(response.errors[0].message).toBe('Unauthorized error') 53 | }) 54 | }) 55 | 56 | describe('SignUp Mutation', () => { 57 | const signupMutation = gql` 58 | mutation signup( 59 | $name: String! 60 | $passwordConfirmation: String! 61 | $email: String! 62 | $password: String! 63 | ) { 64 | signup( 65 | email: $email 66 | password: $password 67 | name: $name 68 | passwordConfirmation: $passwordConfirmation 69 | ) { 70 | accessToken 71 | name 72 | } 73 | } 74 | ` 75 | test('Should return an account on valid data', async () => { 76 | const { mutate } = createTestClient({ apolloServer }) 77 | const response: any = await mutate(signupMutation, { 78 | variables: { 79 | name: fakeAccountParams.name, 80 | email: fakeAccountParams.email, 81 | password: fakeAccountParams.password, 82 | passwordConfirmation: fakeAccountParams.password 83 | } 84 | }) 85 | expect(response.data.signup.accessToken).toBeTruthy() 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/main/graphql/directives/auth-directive.ts: -------------------------------------------------------------------------------- 1 | import { makeAuthMiddleware } from '@main/factories/middlewares/auth-middleware' 2 | import { ForbiddenError } from 'apollo-server-errors' 3 | import { defaultFieldResolver, GraphQLField } from 'graphql' 4 | import { SchemaDirectiveVisitor } from 'graphql-tools' 5 | 6 | export class AuthDirective extends SchemaDirectiveVisitor { 7 | visitFieldDefinition(field: GraphQLField): any { 8 | const { resolve = defaultFieldResolver } = field 9 | field.resolve = async (parent, args, context, info) => { 10 | const request = { accessToken: context?.req?.headers?.['access-token'] } 11 | const response = await makeAuthMiddleware().handle(request) 12 | if (response.statusCode === 200) { 13 | Object.assign(context?.req, response.body) 14 | return resolve.call(this, parent, args, context, info) 15 | } else throw new ForbiddenError(response.body.message) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/graphql/directives/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthDirective } from './auth-directive' 2 | 3 | export default { auth: AuthDirective } 4 | -------------------------------------------------------------------------------- /src/main/graphql/resolvers/base.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLDateTime } from 'graphql-iso-date' 2 | 3 | export default { 4 | DateTime: GraphQLDateTime 5 | } 6 | -------------------------------------------------------------------------------- /src/main/graphql/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import login from './login' 2 | import surveys from './survey' 3 | import surveyResult from './survey-result' 4 | import base from './base' 5 | 6 | export default [login, surveys, base, surveyResult] 7 | -------------------------------------------------------------------------------- /src/main/graphql/resolvers/login.ts: -------------------------------------------------------------------------------- 1 | import { adaptResolver } from '@main/adapters/apollo-server-resolver-adapter' 2 | import { makeLoginController } from '@main/factories/login/login-factory' 3 | import { makeSignUpController } from '@main/factories/signup/signup-factory' 4 | 5 | export default { 6 | Query: { 7 | login: async (parent: any, args: any) => adaptResolver(makeLoginController(), args) 8 | }, 9 | 10 | Mutation: { 11 | signup: async (parent: any, args: any) => adaptResolver(makeSignUpController(), args) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/graphql/resolvers/survey-result.ts: -------------------------------------------------------------------------------- 1 | import { adaptResolver } from '@main/adapters/apollo-server-resolver-adapter' 2 | import { makeLoadSurveyResultController } from '@main/factories/load-survey-result/load-survey-result-controller' 3 | import { makeSaveSurveyResultController } from '@main/factories/save-survey-result/save-survey-result-controller' 4 | 5 | export default { 6 | Query: { 7 | surveyResult: async (parent: any, args: any) => 8 | adaptResolver(makeLoadSurveyResultController(), args) 9 | }, 10 | 11 | Mutation: { 12 | saveSurveyResult: async (parent: any, args: any) => 13 | adaptResolver(makeSaveSurveyResultController(), args) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/graphql/resolvers/survey.ts: -------------------------------------------------------------------------------- 1 | import { adaptResolver } from '@main/adapters/apollo-server-resolver-adapter' 2 | import { makeLoadSurveysController } from '@main/factories/load-survey/load-survey-controller' 3 | 4 | export default { 5 | Query: { 6 | surveys: async () => adaptResolver(makeLoadSurveysController()) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/graphql/type-defs/base.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express' 2 | 3 | export default gql` 4 | scalar DateTime 5 | 6 | directive @auth on FIELD_DEFINITION 7 | 8 | type Query { 9 | _: String 10 | } 11 | 12 | type Mutation { 13 | _: String 14 | } 15 | ` 16 | -------------------------------------------------------------------------------- /src/main/graphql/type-defs/index.ts: -------------------------------------------------------------------------------- 1 | import base from './base' 2 | import login from './login' 3 | import survey from './survey' 4 | import surveyResult from './survey-result' 5 | 6 | export default [base, login, survey, surveyResult] 7 | -------------------------------------------------------------------------------- /src/main/graphql/type-defs/login.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express' 2 | 3 | export default gql` 4 | type Account { 5 | accessToken: String! 6 | name: String! 7 | } 8 | 9 | extend type Query { 10 | login(email: String!, password: String!): Account! 11 | } 12 | 13 | extend type Mutation { 14 | signup( 15 | email: String! 16 | name: String! 17 | password: String! 18 | passwordConfirmation: String! 19 | ): Account! 20 | } 21 | ` 22 | -------------------------------------------------------------------------------- /src/main/graphql/type-defs/survey-result.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express' 2 | 3 | export default gql` 4 | type SurveyResult { 5 | surveyId: String! 6 | question: String! 7 | date: DateTime! 8 | answers: [Answer!]! 9 | didAnswer: Boolean 10 | } 11 | 12 | type Answer { 13 | image: String 14 | answer: String! 15 | count: Int! 16 | percent: Int! 17 | isCurrentAccountResponse: Boolean! 18 | } 19 | 20 | extend type Query { 21 | surveyResult(surveyId: String!): SurveyResult! @auth 22 | } 23 | 24 | extend type Mutation { 25 | saveSurveyResult(surveyId: String!, answer: String!): SurveyResult! @auth 26 | } 27 | ` 28 | -------------------------------------------------------------------------------- /src/main/graphql/type-defs/survey.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express' 2 | 3 | export default gql` 4 | type Survey { 5 | id: ID! 6 | question: String! 7 | answers: [SurveyAnswer!]! 8 | date: DateTime! 9 | didAnswer: Boolean 10 | } 11 | 12 | type SurveyAnswer { 13 | image: String 14 | answer: String! 15 | } 16 | 17 | extend type Query { 18 | surveys: [Survey!]! @auth 19 | } 20 | ` 21 | -------------------------------------------------------------------------------- /src/main/middlewares/adm-auth.ts: -------------------------------------------------------------------------------- 1 | import { adaptMiddleware } from '@main/adapters/express-middleware' 2 | import { makeAuthMiddleware } from '@main/factories/middlewares/auth-middleware' 3 | 4 | export const AdmAuth = adaptMiddleware(makeAuthMiddleware('admin')) 5 | -------------------------------------------------------------------------------- /src/main/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import { adaptMiddleware } from '@main/adapters/express-middleware' 2 | import { makeAuthMiddleware } from '@main/factories/middlewares/auth-middleware' 3 | 4 | export const Auth = adaptMiddleware(makeAuthMiddleware()) 5 | -------------------------------------------------------------------------------- /src/main/middlewares/body-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import app from '../config/app' 3 | 4 | describe('Body Parser Middleware', () => { 5 | const data = { name: 'any_name' } 6 | 7 | test('Should parser body as json', async () => { 8 | app.post('/test_body_parser', (req, res) => res.send(req.body)) 9 | await request(app).post('/test_body_parser').send(data).expect(data) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/main/middlewares/body-parser.ts: -------------------------------------------------------------------------------- 1 | import { json } from 'express' 2 | 3 | export const bodyParser = json() 4 | -------------------------------------------------------------------------------- /src/main/middlewares/content-type.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import app from '../config/app' 3 | 4 | describe('Json Content Type Middleware', () => { 5 | test('Should return default content type as json', async () => { 6 | app.get('/test_content_type', (req, res) => res.send('')) 7 | await request(app).get('/test_content_type').expect('content-type', /json/) 8 | }) 9 | 10 | test('Should return default xml content when forced', async () => { 11 | app.get('/test_xml_content_type', (req, res) => { 12 | res.type('xml') 13 | res.send('') 14 | }) 15 | await request(app).get('/test_xml_content_type').expect('content-type', /xml/) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/main/middlewares/content-type.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | 3 | export const contentType = ( 4 | req: Request, 5 | res: Response, 6 | next: NextFunction 7 | ) => { 8 | res.type('json') 9 | next() 10 | } 11 | -------------------------------------------------------------------------------- /src/main/middlewares/cors.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import app from '../config/app' 3 | 4 | describe('CORS Middleware', () => { 5 | test('Should enable CORS', async () => { 6 | app.get('/test_cors', (req, res) => res.send()) 7 | await request(app) 8 | .post('/test_cors') 9 | .expect('access-control-allow-origin', '*') 10 | .expect('access-control-allow-methods', '*') 11 | .expect('access-control-allow-headers', '*') 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /src/main/middlewares/cors.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | 3 | export const cors = (req: Request, res: Response, next: NextFunction) => { 4 | res.set('access-control-allow-origin', '*') 5 | res.set('access-control-allow-methods', '*') 6 | res.set('access-control-allow-headers', '*') 7 | next() 8 | } 9 | -------------------------------------------------------------------------------- /src/main/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cors' 2 | export * from './content-type' 3 | export * from './body-parser' 4 | -------------------------------------------------------------------------------- /src/main/middlewares/no-cache.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import app from '../config/app' 3 | import { noCache } from './no-cache' 4 | 5 | describe('NoCache Middleware', () => { 6 | test('Should disable cache', async () => { 7 | app.get('/test_cache', noCache, (req, res) => res.send()) 8 | await request(app) 9 | .get('/test_cache') 10 | .expect('cache-control', 'no-store, no-cache, must-revalidate, proxy-revalidate') 11 | .expect('pragma', 'no-cache') 12 | .expect('expires', '0') 13 | .expect('surrogate-control', 'no-store') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/main/middlewares/no-cache.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | 3 | export const noCache = (req: Request, res: Response, next: NextFunction) => { 4 | res.set('cache-control', 'no-store, no-cache, must-revalidate, proxy-revalidate') 5 | res.set('pragma', 'no-cache') 6 | res.set('expires', '0') 7 | res.set('surrogate-control', 'no-store') 8 | next() 9 | } 10 | -------------------------------------------------------------------------------- /src/main/routes/login-routes.test.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcrypt' 2 | import { Collection } from 'mongodb' 3 | import request from 'supertest' 4 | import { fakeAccountParams, fakeLoginParams } from '../../domain/mocks/mock-account' 5 | import { MongoHelper } from '../../infra/db/mongodb/helpers/index' 6 | import app from '../config/app' 7 | 8 | let accountCollection: Collection 9 | 10 | describe('Login Routes', () => { 11 | beforeAll(async () => await MongoHelper.connect(process.env.MONGO_URL)) 12 | afterAll(async () => await MongoHelper.disconnect()) 13 | beforeEach(async () => { 14 | accountCollection = await MongoHelper.getCollection('accounts') 15 | await accountCollection.deleteMany({}) 16 | }) 17 | 18 | test('Should return 200 on login', async () => { 19 | const hashedPassword = await hash(fakeAccountParams.password, 12) 20 | await accountCollection.insertOne({ ...fakeAccountParams, password: hashedPassword }) 21 | 22 | app.post('/api/login', (req, res) => res.send()) 23 | await request(app).post('/api/login').send(fakeLoginParams).expect(200) 24 | }) 25 | 26 | test('Should return 401 on unauthorized request', async () => { 27 | app.post('/api/login', (req, res) => res.send()) 28 | await request(app).post('/api/login').send(fakeLoginParams).expect(401) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/main/routes/logn-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { makeLoginController } from '../factories/login/login-factory' 3 | import { adaptRoutes } from '../adapters/express-routes' 4 | 5 | const loginController = makeLoginController() 6 | 7 | export default (router: Router): void => { 8 | router.post('/login', adaptRoutes(loginController)) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/routes/signup-routes.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { fakeAccountParams } from '../../domain/mocks/mock-account' 3 | import { MongoHelper } from '../../infra/db/mongodb/helpers/index' 4 | import app from '../config/app' 5 | 6 | describe('SigUp Routes', () => { 7 | beforeAll(async () => await MongoHelper.connect(process.env.MONGO_URL)) 8 | afterAll(async () => await MongoHelper.disconnect()) 9 | beforeEach(async () => { 10 | const accountCollection = await MongoHelper.getCollection('accounts') 11 | await accountCollection.deleteMany({}) 12 | }) 13 | 14 | test('Should return 200 on sign', async () => { 15 | app.post('/api/signup', (req, res) => res.send()) 16 | await request(app) 17 | .post('/api/signup') 18 | .send({ ...fakeAccountParams, passwordConfirmation: 'any_password' }) 19 | .expect(200) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/main/routes/signup-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { makeSignUpController } from '../factories/signup/signup-factory' 3 | import { adaptRoutes } from '../adapters/express-routes' 4 | 5 | const SignUpController = makeSignUpController() 6 | 7 | export default (router: Router): void => { 8 | router.post('/signup', adaptRoutes(SignUpController)) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/routes/survey-result-routes.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { Collection } from 'mongodb' 3 | import app from '../config/app' 4 | import { MongoHelper } from '../../infra/db/mongodb/helpers/mongo' 5 | import { sign } from 'jsonwebtoken' 6 | import env from '../config/env' 7 | import { fakeSurveyParams } from '../../domain/mocks/mock-survey' 8 | import { fakeAccountParams } from '../../domain/mocks/mock-account' 9 | 10 | let surveyCollection: Collection 11 | let accountCollection: Collection 12 | 13 | describe('Survey Routes', () => { 14 | beforeAll(async () => await MongoHelper.connect(process.env.MONGO_URL)) 15 | afterAll(async () => await MongoHelper.disconnect()) 16 | beforeEach(async () => { 17 | surveyCollection = await MongoHelper.getCollection('surveys') 18 | await surveyCollection.deleteMany({}) 19 | 20 | accountCollection = await MongoHelper.getCollection('accounts') 21 | await accountCollection.deleteMany({}) 22 | }) 23 | 24 | describe('PUT /surveys/:surveyId/results', () => { 25 | test('Should return 403 on save survey result without accessToken', async () => { 26 | await request(app) 27 | .put('/api/surveys/any_id/results') 28 | .send({ answer: 'any_answer' }) 29 | .expect(403) 30 | }) 31 | 32 | test('Should return 200 on save survey result with accessToken', async () => { 33 | const res = await surveyCollection.insertOne({ ...fakeSurveyParams, date: new Date() }) 34 | 35 | const account = await accountCollection.insertOne(fakeAccountParams) 36 | const id = account.ops[0]._id 37 | const accessToken = sign({ id }, env.jwtSecret) 38 | await accountCollection.updateOne({ _id: id }, { $set: { accessToken } }) 39 | 40 | await request(app) 41 | .put(`/api/surveys/${res.ops[0]._id}/results`) 42 | .set('access-token', accessToken) 43 | .send({ answer: 'any_answer' }) 44 | .expect(200) 45 | }) 46 | }) 47 | 48 | describe('GET /surveys/:surveyId/results', () => { 49 | test('Should return 403 on GET, without accessToken', async () => { 50 | await request(app).get('/api/surveys/any_id/results').expect(403) 51 | }) 52 | 53 | test('Should return 200 on load survey result with accessToken', async () => { 54 | const res = await surveyCollection.insertOne({ ...fakeSurveyParams, date: new Date() }) 55 | 56 | const account = await accountCollection.insertOne(fakeAccountParams) 57 | const id = account.ops[0]._id 58 | const accessToken = sign({ id }, env.jwtSecret) 59 | await accountCollection.updateOne({ _id: id }, { $set: { accessToken } }) 60 | 61 | await request(app) 62 | .get(`/api/surveys/${res.ops[0]._id}/results`) 63 | .set('access-token', accessToken) 64 | .expect(200) 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/main/routes/survey-results-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { adaptRoutes } from '../adapters/express-routes' 3 | import { makeSaveSurveyResultController } from '@main/factories/save-survey-result/save-survey-result-controller' 4 | import { makeLoadSurveyResultController } from '@main/factories/load-survey-result/load-survey-result-controller' 5 | import { Auth } from '@main/middlewares/auth' 6 | 7 | const saveSurveyResultController = makeSaveSurveyResultController() 8 | const loadSurveyResultController = makeLoadSurveyResultController() 9 | 10 | export default (router: Router): void => { 11 | router.put('/surveys/:surveyId/results', Auth, adaptRoutes(saveSurveyResultController)) 12 | router.get('/surveys/:surveyId/results', Auth, adaptRoutes(loadSurveyResultController)) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/routes/survey-routes.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { Collection } from 'mongodb' 3 | import app from '../config/app' 4 | import { MongoHelper } from '../../infra/db/mongodb/helpers/mongo' 5 | import { sign } from 'jsonwebtoken' 6 | import env from '../config/env' 7 | import { fakeSurveyParams } from '../../domain/mocks/mock-survey' 8 | import { fakeAccountParams } from '../../domain/mocks/mock-account' 9 | 10 | let surveyCollection: Collection 11 | let accountCollection: Collection 12 | 13 | describe('Survey Routes', () => { 14 | beforeAll(async () => await MongoHelper.connect(process.env.MONGO_URL)) 15 | afterAll(async () => await MongoHelper.disconnect()) 16 | beforeEach(async () => { 17 | surveyCollection = await MongoHelper.getCollection('surveys') 18 | await surveyCollection.deleteMany({}) 19 | 20 | accountCollection = await MongoHelper.getCollection('accounts') 21 | await accountCollection.deleteMany({}) 22 | }) 23 | 24 | describe('POST /surveys', () => { 25 | test('Should return 403 on add survey without accessToken', async () => { 26 | await request(app).post('/api/surveys').send(fakeSurveyParams).expect(403) 27 | }) 28 | 29 | test('Should return 204 on add survey with valid accessToken', async () => { 30 | const account = await accountCollection.insertOne({ ...fakeAccountParams, role: 'adm' }) 31 | const id = account.ops[0]._id 32 | const accessToken = sign({ id }, env.jwtSecret) 33 | await accountCollection.updateOne({ _id: id }, { $set: { accessToken } }) 34 | 35 | await request(app) 36 | .post('/api/surveys') 37 | .set('access-token', accessToken) 38 | .send(fakeSurveyParams) 39 | .expect(204) 40 | }) 41 | }) 42 | 43 | describe('GET /surveys', () => { 44 | test('Should return 403 on load survey without accessToken', async () => { 45 | await request(app).get('/api/surveys').expect(403) 46 | }) 47 | 48 | test('Should return 204 on load survey with valid accessToken', async () => { 49 | const account = await accountCollection.insertOne(fakeAccountParams) 50 | const id = account.ops[0]._id 51 | const accessToken = sign({ id }, env.jwtSecret) 52 | await accountCollection.updateOne({ _id: id }, { $set: { accessToken } }) 53 | await request(app).get('/api/surveys').set('access-token', accessToken).expect(204) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/main/routes/survey-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { adaptRoutes } from '../adapters/express-routes' 3 | import { makeAddSurveyController } from '@main/factories/add-survey/add-survey-controller-factory' 4 | import { makeLoadSurveysController } from '@main/factories/load-survey/load-survey-controller' 5 | import { AdmAuth } from '@main/middlewares/adm-auth' 6 | import { Auth } from '@main/middlewares/auth' 7 | 8 | const addSurveyController = makeAddSurveyController() 9 | const loadSurveyController = makeLoadSurveysController() 10 | 11 | export default (router: Router): void => { 12 | router.post('/surveys', AdmAuth, adaptRoutes(addSurveyController)) 13 | router.get('/surveys', Auth, adaptRoutes(loadSurveyController)) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/server.ts: -------------------------------------------------------------------------------- 1 | import { MongoHelper } from '@infra/db/mongodb/helpers/index' 2 | import env from './config/env' 3 | 4 | MongoHelper.connect(env.mongoUrl) 5 | .then(async () => { 6 | console.log(`Mongodb connected successfully at ${env.mongoUrl}`) 7 | 8 | const app = (await import('./config/app')).default 9 | const port = env.port 10 | 11 | app.listen(port, () => { 12 | console.log('Server running at http://localhost:' + port) 13 | }) 14 | }) 15 | .catch(err => console.error(err)) 16 | -------------------------------------------------------------------------------- /src/presentation/controllers/login/login-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeLoginParams } from '../../../domain/mocks/mock-account' 2 | import { AuthenticationModel } from '../../../domain/models/account' 3 | import { AuthenticationParams, Authenticator } from '../../../domain/usecases/authentication' 4 | import { MissingParamError } from '../../errors' 5 | import { badRequest, ok, serverError, unauthorized } from '../../helpers/http/http' 6 | import { Validation } from '../../interfaces/validation' 7 | import { LoginController } from './login-controller' 8 | 9 | class MockValidation implements Validation { 10 | validate(input: any): Error { 11 | return null 12 | } 13 | } 14 | 15 | class MockAuthenticator implements Authenticator { 16 | async authenticate(data: AuthenticationParams): Promise { 17 | return new Promise(resolve => resolve({ accessToken: 'access_token', name: 'any_name' })) 18 | } 19 | } 20 | 21 | export const fakeRequest: LoginController.Request = { ...fakeLoginParams } 22 | 23 | describe('Login Controller', () => { 24 | const mockValidation = new MockValidation() as jest.Mocked 25 | const mockAuthenticator = new MockAuthenticator() as jest.Mocked 26 | const sut = new LoginController(mockValidation, mockAuthenticator) 27 | 28 | describe('Authentication', () => { 29 | test('Should call Authenticator with correct values', async () => { 30 | const authenticateSpy = jest.spyOn(mockAuthenticator, 'authenticate') 31 | await sut.handle(fakeRequest) 32 | expect(authenticateSpy).toHaveBeenCalledWith({ ...fakeLoginParams }) 33 | }) 34 | 35 | test('Should return 401 if invalid credentials are provided', async () => { 36 | mockAuthenticator.authenticate.mockReturnValueOnce(null) 37 | const httpResponse = await sut.handle(fakeRequest) 38 | expect(httpResponse).toEqual(unauthorized()) 39 | }) 40 | 41 | test('Should return 500 if Authenticators throws', async () => { 42 | mockAuthenticator.authenticate.mockRejectedValueOnce(new Error()) 43 | const httpResponse = await sut.handle(fakeRequest) 44 | expect(httpResponse).toEqual(serverError(new Error())) 45 | }) 46 | 47 | test('Should return 200 if valid credentials are provided', async () => { 48 | const httpResponse = await sut.handle(fakeRequest) 49 | expect(httpResponse).toEqual(ok({ accessToken: 'access_token', name: 'any_name' })) 50 | }) 51 | }) 52 | 53 | describe('Validation', () => { 54 | test('Should call Validation with correct values', async () => { 55 | const validateSpy = jest.spyOn(mockValidation, 'validate') 56 | const httpRequest = fakeRequest 57 | await sut.handle(httpRequest) 58 | expect(validateSpy).toHaveBeenCalledWith(httpRequest) 59 | }) 60 | 61 | test('Should return 400 if Validation returns an Error', async () => { 62 | // could be any error (below) 63 | mockValidation.validate.mockReturnValueOnce(new MissingParamError('any_field')) 64 | const httpResponse = await sut.handle(fakeRequest) 65 | expect(httpResponse).toEqual(badRequest(new MissingParamError('any_field'))) 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/presentation/controllers/login/login-controller.ts: -------------------------------------------------------------------------------- 1 | import { badRequest, serverError, unauthorized, ok } from '../../helpers/http/http' 2 | import { Authenticator } from '@domain/usecases/authentication' 3 | import { Controller, HttpResponse, Validation } from '../../interfaces' 4 | 5 | export class LoginController implements Controller { 6 | constructor( 7 | private readonly validation: Validation, 8 | private readonly authenticator: Authenticator 9 | ) {} 10 | 11 | async handle(request: LoginController.Request): Promise { 12 | try { 13 | const error = this.validation.validate(request) 14 | if (error) return badRequest(error) 15 | 16 | const authModel = await this.authenticator.authenticate(request) 17 | if (!authModel) return unauthorized() 18 | 19 | return ok(authModel) 20 | } catch (error) { 21 | return serverError(error) 22 | } 23 | } 24 | } 25 | 26 | export namespace LoginController { 27 | export interface Request { 28 | email: string 29 | password: string 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/presentation/controllers/signup/signup-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAccountModel, fakeAccountParams } from '../../../domain/mocks/mock-account' 2 | import { AccountModel, AuthenticationModel } from '../../../domain/models/account' 3 | import { AddAccount, AddAccountParams } from '../../../domain/usecases/add-account' 4 | import { AuthenticationParams, Authenticator } from '../../../domain/usecases/authentication' 5 | import { 6 | EmailAlreadyInUseError, 7 | MissingParamError, 8 | ServerError 9 | } from '../../../presentation/errors/index' 10 | import { badRequest, forbidden, ok, serverError } from '../../helpers/http/http' 11 | import { Validation } from '../../interfaces/validation' 12 | import { SignUpController } from './signup-controller' 13 | 14 | class MockAddAccount implements AddAccount { 15 | async add(account: AddAccountParams): Promise { 16 | return new Promise(resolve => resolve(fakeAccountModel)) 17 | } 18 | } 19 | 20 | class MockValidation implements Validation { 21 | validate(input: any): Error { 22 | return null 23 | } 24 | } 25 | 26 | class MockAuthenticator implements Authenticator { 27 | async authenticate(data: AuthenticationParams): Promise { 28 | return new Promise(resolve => resolve({ accessToken: 'access_token', name: 'any_name' })) 29 | } 30 | } 31 | 32 | export const fakeRequest: SignUpController.Request = { 33 | name: fakeAccountParams.name, 34 | email: fakeAccountParams.email, 35 | password: fakeAccountParams.password, 36 | passwordConfirmation: 'any_password' 37 | } 38 | 39 | describe('SingUp Controller', () => { 40 | const mockValidation = new MockValidation() as jest.Mocked 41 | const mockAddAccount = new MockAddAccount() as jest.Mocked 42 | const mockAuthenticator = new MockAuthenticator() as jest.Mocked 43 | const sut = new SignUpController(mockValidation, mockAddAccount, mockAuthenticator) 44 | 45 | describe('AddAccount', () => { 46 | test('Should call AddAccount with correct values', async () => { 47 | const addSpy = jest.spyOn(mockAddAccount, 'add') 48 | await sut.handle(fakeRequest) 49 | expect(addSpy).toHaveBeenCalledWith({ ...fakeAccountParams }) 50 | }) 51 | 52 | test('Should return 500 if AddAccount throws', async () => { 53 | mockAddAccount.add.mockRejectedValueOnce(new Error()) 54 | const httpResponse = await sut.handle(fakeRequest) 55 | expect(httpResponse).toEqual(serverError(new ServerError())) 56 | }) 57 | 58 | test('Should return 200 if valid data is provided', async () => { 59 | const httpResponse = await sut.handle(fakeRequest) 60 | expect(httpResponse).toEqual(ok({ accessToken: 'access_token', name: 'any_name' })) 61 | }) 62 | 63 | test('Should return 403 if addAccount returns null', async () => { 64 | mockAddAccount.add.mockResolvedValueOnce(null) 65 | const httpResponse = await sut.handle(fakeRequest) 66 | expect(httpResponse).toEqual(forbidden(new EmailAlreadyInUseError())) 67 | }) 68 | }) 69 | 70 | describe('Validations', () => { 71 | test('Should call Validation with correct values', async () => { 72 | const validateSpy = jest.spyOn(mockValidation, 'validate') 73 | const httpRequest = fakeRequest 74 | await sut.handle(httpRequest) 75 | expect(validateSpy).toHaveBeenCalledWith(httpRequest) 76 | }) 77 | 78 | test('Should return 400 if Validation returns an Error', async () => { 79 | // could be any error (below) 80 | mockValidation.validate.mockReturnValueOnce(new MissingParamError('any_field')) 81 | const httpResponse = await sut.handle(fakeRequest) 82 | expect(httpResponse).toEqual(badRequest(new MissingParamError('any_field'))) 83 | }) 84 | }) 85 | 86 | describe('Authentication', () => { 87 | test('Should call Authenticator with correct values', async () => { 88 | const authenticateSpy = jest.spyOn(mockAuthenticator, 'authenticate') 89 | await sut.handle(fakeRequest) 90 | expect(authenticateSpy).toHaveBeenCalledWith({ 91 | email: fakeAccountParams.email, 92 | password: fakeAccountParams.password 93 | }) 94 | }) 95 | 96 | test('Should return 500 if Authenticators throws', async () => { 97 | mockAuthenticator.authenticate.mockRejectedValueOnce(new Error()) 98 | const httpResponse = await sut.handle(fakeRequest) 99 | expect(httpResponse).toEqual(serverError(new Error())) 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /src/presentation/controllers/signup/signup-controller.ts: -------------------------------------------------------------------------------- 1 | import { badRequest, serverError, ok, forbidden } from '@presentation/helpers/http/http' 2 | import { AddAccount } from '@domain/usecases/add-account' 3 | import { Authenticator } from '@domain/usecases/authentication' 4 | import { EmailAlreadyInUseError } from '@presentation/errors' 5 | import { Controller, Validation, HttpResponse } from '@presentation/interfaces' 6 | 7 | export class SignUpController implements Controller { 8 | constructor( 9 | private readonly validation: Validation, 10 | private readonly addAccount: AddAccount, 11 | private readonly authentication: Authenticator 12 | ) {} 13 | 14 | async handle(request: SignUpController.Request): Promise { 15 | try { 16 | const error = this.validation.validate(request) 17 | if (error) return badRequest(error) 18 | 19 | const { email, name, password } = request 20 | const account = await this.addAccount.add({ name, email, password }) 21 | if (!account) return forbidden(new EmailAlreadyInUseError()) 22 | const authModel = await this.authentication.authenticate({ email, password }) 23 | 24 | return ok(authModel) 25 | } catch (error) { 26 | return serverError(error) 27 | } 28 | } 29 | } 30 | 31 | export namespace SignUpController { 32 | export interface Request { 33 | name: string 34 | email: string 35 | password: string 36 | passwordConfirmation: string 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey-result/load-survey-result-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyResultController } from './load-survey-result-controller' 2 | import { LoadSurveyById } from '../../../domain/usecases/load-survey' 3 | import { SurveyModel } from '../../../domain/models/survey' 4 | import { InvalidParamError } from '../../errors/invalid-param' 5 | import { forbidden, ok, serverError } from '../../helpers/http/http' 6 | import { LoadSurveyResult } from '../../../domain/usecases/load-survey-results' 7 | import { SurveyResultsModel } from '../../../domain/models/survey-results' 8 | import { fakeSurveyResultModel } from '../../../domain/mocks/mock-survey-result' 9 | import { fakeSurveyModel } from '../../../domain/mocks/mock-survey' 10 | import mockDate from 'mockdate' 11 | 12 | const fakeRequest: LoadSurveyResultController.Request = { accountId: 'any_id', surveyId: 'any_id' } 13 | 14 | class MockLoadSurveyById implements LoadSurveyById { 15 | async loadById(id: string): Promise { 16 | return fakeSurveyModel 17 | } 18 | } 19 | 20 | class MockLoadSurveyResult implements LoadSurveyResult { 21 | async load(surveyId: string, accountId: string): Promise { 22 | return fakeSurveyResultModel 23 | } 24 | } 25 | 26 | describe('LoadSurvey Controller', () => { 27 | const mockLoadSurveyById = new MockLoadSurveyById() as jest.Mocked 28 | const mockLoadSurveyResult = new MockLoadSurveyResult() as jest.Mocked 29 | const sut = new LoadSurveyResultController(mockLoadSurveyById, mockLoadSurveyResult) 30 | 31 | beforeAll(() => mockDate.set(new Date())) 32 | afterAll(() => mockDate.reset()) 33 | 34 | test('Should call LoadSurveyById with correct values', async () => { 35 | const loadSpy = jest.spyOn(mockLoadSurveyById, 'loadById') 36 | await sut.handle(fakeRequest) 37 | expect(loadSpy).toHaveBeenCalledWith(fakeRequest.surveyId) 38 | }) 39 | 40 | test('Should return 403 if an invalid survey id is provided', async () => { 41 | mockLoadSurveyById.loadById.mockResolvedValueOnce(null) 42 | const response = await sut.handle(fakeRequest) 43 | expect(response).toEqual(forbidden(new InvalidParamError('surveyId'))) 44 | }) 45 | 46 | test('Should return 500 if LoadSurveyById throws', async () => { 47 | mockLoadSurveyById.loadById.mockRejectedValueOnce(new Error()) 48 | const response = await sut.handle(fakeRequest) 49 | expect(response).toEqual(serverError(new Error())) 50 | }) 51 | 52 | test('Should call LoadSurveyResult with correct values', async () => { 53 | const loadSpy = jest.spyOn(mockLoadSurveyResult, 'load') 54 | await sut.handle(fakeRequest) 55 | expect(loadSpy).toHaveBeenCalledWith(fakeRequest.surveyId, fakeRequest.accountId) 56 | }) 57 | 58 | test('Should return 500 if LoadSurveyResult throws', async () => { 59 | mockLoadSurveyResult.load.mockRejectedValueOnce(new Error()) 60 | const response = await sut.handle(fakeRequest) 61 | expect(response).toEqual(serverError(new Error())) 62 | }) 63 | 64 | test('Should return 200 on success', async () => { 65 | const response = await sut.handle(fakeRequest) 66 | expect(response).toEqual(ok(fakeSurveyResultModel)) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey-result/load-survey-result-controller.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyById } from '@domain/usecases/load-survey' 2 | import { forbidden, ok, serverError } from '@presentation/helpers/http/http' 3 | import { InvalidParamError } from '@presentation/errors' 4 | import { Controller, HttpResponse } from '@presentation/interfaces' 5 | import { LoadSurveyResult } from '@domain/usecases/load-survey-results' 6 | 7 | export class LoadSurveyResultController implements Controller { 8 | constructor( 9 | private readonly loadSurveyById: LoadSurveyById, 10 | private readonly loadSurveyResult: LoadSurveyResult 11 | ) {} 12 | 13 | async handle(request: LoadSurveyResultController.Request): Promise { 14 | try { 15 | const { surveyId } = request 16 | const survey = await this.loadSurveyById.loadById(surveyId) 17 | if (!survey) return forbidden(new InvalidParamError('surveyId')) 18 | 19 | const surveyResult = await this.loadSurveyResult.load(surveyId, request.accountId) 20 | return ok(surveyResult) 21 | } catch (error) { 22 | return serverError(error) 23 | } 24 | } 25 | } 26 | 27 | export namespace LoadSurveyResultController { 28 | export interface Request { 29 | surveyId: string 30 | accountId: string 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey-result/save-survey-result-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import mockDate from 'mockdate' 2 | import { fakeSurveyModel } from '../../../domain/mocks/mock-survey' 3 | import { fakeSurveyResultModel } from '../../../domain/mocks/mock-survey-result' 4 | import { SurveyModel } from '../../../domain/models/survey' 5 | import { SurveyResultsModel } from '../../../domain/models/survey-results' 6 | import { LoadSurveyById } from '../../../domain/usecases/load-survey' 7 | import { 8 | SaveSurveyResultParams, 9 | SaveSurveyResults 10 | } from '../../../domain/usecases/save-survey-results' 11 | import { InvalidParamError } from '../../errors/invalid-param' 12 | import { forbidden, ok, serverError } from '../../helpers/http/http' 13 | import { SaveSurveyResultController } from './save-survey-result-controller' 14 | 15 | class MockLoadSurveyById implements LoadSurveyById { 16 | async loadById(id: string): Promise { 17 | return fakeSurveyModel 18 | } 19 | } 20 | 21 | class MockSaveSurveyResult implements SaveSurveyResults { 22 | async save(data: SaveSurveyResultParams): Promise { 23 | return fakeSurveyResultModel 24 | } 25 | } 26 | 27 | describe('Save survey result controller', () => { 28 | const mockLoadSurveyById = new MockLoadSurveyById() as jest.Mocked 29 | const mockSaveSurveyResult = new MockSaveSurveyResult() as jest.Mocked 30 | const sut = new SaveSurveyResultController(mockLoadSurveyById, mockSaveSurveyResult) 31 | 32 | const fakeRequest: SaveSurveyResultController.Request = { 33 | surveyId: 'any_id', 34 | answer: 'any_answer', 35 | accountId: 'any_account_id' 36 | } 37 | 38 | beforeAll(() => mockDate.set(new Date())) 39 | afterAll(() => mockDate.reset()) 40 | 41 | describe('Load Survey By Id', () => { 42 | test('Should call LoadSurveyById with correct values', async () => { 43 | const loadSpy = jest.spyOn(mockLoadSurveyById, 'loadById') 44 | await sut.handle(fakeRequest) 45 | expect(loadSpy).toHaveBeenCalledWith('any_id') 46 | }) 47 | 48 | test('Should return 403 if LoadSurveyById returns null', async () => { 49 | mockLoadSurveyById.loadById.mockResolvedValueOnce(null) 50 | const response = await sut.handle(fakeRequest) 51 | expect(response).toEqual(forbidden(new InvalidParamError('surveyId'))) 52 | }) 53 | 54 | test('Should return 500 if LoadSurveyById throws', async () => { 55 | mockLoadSurveyById.loadById.mockRejectedValueOnce(new Error()) 56 | const response = await sut.handle(fakeRequest) 57 | expect(response).toEqual(serverError(new Error())) 58 | }) 59 | }) 60 | 61 | describe('Save Survey Result', () => { 62 | test('Should call SaveSurveyResult with correct values', async () => { 63 | const saveSpy = jest.spyOn(mockSaveSurveyResult, 'save') 64 | await sut.handle(fakeRequest) 65 | expect(saveSpy).toHaveBeenCalledWith({ 66 | surveyId: 'any_id', 67 | accountId: 'any_account_id', 68 | date: new Date(), 69 | answer: 'any_answer' 70 | }) 71 | }) 72 | 73 | test('Should return 403 if an invalid response is provided', async () => { 74 | const response = await sut.handle({ 75 | surveyId: 'any_id', 76 | answer: 'wrong_answer', 77 | accountId: 'any_account_id' 78 | }) 79 | expect(response).toEqual(forbidden(new InvalidParamError('answer'))) 80 | }) 81 | 82 | test('Should return 500 if SaveSurveyResults throws', async () => { 83 | mockSaveSurveyResult.save.mockRejectedValueOnce(new Error()) 84 | const response = await sut.handle(fakeRequest) 85 | expect(response).toEqual(serverError(new Error())) 86 | }) 87 | 88 | test('Should return 200 on SaveSurveyResults success', async () => { 89 | const response = await sut.handle(fakeRequest) 90 | expect(response).toEqual(ok(fakeSurveyResultModel)) 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey-result/save-survey-result-controller.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurveyById } from '@domain/usecases/load-survey' 2 | import { SaveSurveyResults } from '@domain/usecases/save-survey-results' 3 | import { InvalidParamError } from '@presentation/errors' 4 | import { forbidden, ok, serverError } from '@presentation/helpers/http/http' 5 | import { Controller, HttpResponse } from '../../interfaces/' 6 | 7 | export class SaveSurveyResultController implements Controller { 8 | constructor( 9 | private readonly loadSurveyById: LoadSurveyById, 10 | private readonly saveSurveyResult: SaveSurveyResults 11 | ) {} 12 | 13 | async handle(request: SaveSurveyResultController.Request): Promise { 14 | try { 15 | const { surveyId, answer, accountId } = request 16 | const survey = await this.loadSurveyById.loadById(surveyId) 17 | if (!survey) return forbidden(new InvalidParamError('surveyId')) 18 | 19 | const answers = survey.answers.map(a => a.answer) 20 | if (!answers.includes(answer)) return forbidden(new InvalidParamError('answer')) 21 | 22 | const surveyResult = await this.saveSurveyResult.save({ 23 | accountId, 24 | surveyId, 25 | answer, 26 | date: new Date() 27 | }) 28 | return ok(surveyResult) 29 | } catch (error) { 30 | console.log(error) 31 | return serverError(error) // λ 32 | } 33 | } 34 | } 35 | 36 | export namespace SaveSurveyResultController { 37 | export interface Request { 38 | surveyId: string 39 | accountId: string 40 | answer: string 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey/add-survey/add-survey.spec.ts: -------------------------------------------------------------------------------- 1 | import MockDate from 'mockdate' 2 | import { fakeSurveyParams } from '../../../../domain/mocks/mock-survey' 3 | import { AddSurvey, AddSurveyParams } from '../../../../domain/usecases/add-survey' 4 | import { badRequest, noContent, serverError } from '../../../helpers/http/http' 5 | import { Validation } from '../../../interfaces' 6 | import { AddSurveyController } from './add-survey' 7 | 8 | class MockValidation implements Validation { 9 | validate(input: any): Error { 10 | return null 11 | } 12 | } 13 | 14 | class MockAddSurvey implements AddSurvey { 15 | async add(data: AddSurveyParams): Promise { 16 | return new Promise(resolve => resolve()) 17 | } 18 | } 19 | 20 | describe('AddSurvey Controller', () => { 21 | const mockValidation = new MockValidation() as jest.Mocked 22 | const mockAddSurvey = new MockAddSurvey() as jest.Mocked 23 | const sut = new AddSurveyController(mockValidation, mockAddSurvey) 24 | 25 | beforeAll(() => MockDate.set(new Date())) 26 | afterAll(() => MockDate.reset) 27 | 28 | const fakeRequest: AddSurveyController.Request = { 29 | question: fakeSurveyParams.question, 30 | answers: fakeSurveyParams.answers 31 | } 32 | 33 | describe('Validation', () => { 34 | test('Should call Validation with correct values', async () => { 35 | const validateSpy = jest.spyOn(mockValidation, 'validate') 36 | await sut.handle(fakeRequest) 37 | expect(validateSpy).toHaveBeenCalledWith(fakeRequest) 38 | }) 39 | 40 | test('Should return 400 if Validation fails', async () => { 41 | mockValidation.validate.mockReturnValueOnce(new Error()) 42 | const httpResponse = await sut.handle(fakeRequest) 43 | expect(httpResponse).toEqual(badRequest(new Error())) 44 | }) 45 | }) 46 | 47 | describe('AddSurvey', () => { 48 | test('Should call AddSurvey with correct values', async () => { 49 | const addSpy = jest.spyOn(mockAddSurvey, 'add') 50 | await sut.handle(fakeRequest) 51 | expect(addSpy).toHaveBeenCalledWith({ ...fakeRequest, date: new Date() }) 52 | }) 53 | 54 | test('Should return 500 if AddSurvey throws', async () => { 55 | mockAddSurvey.add.mockRejectedValueOnce(new Error()) 56 | const error = await sut.handle(fakeRequest) 57 | expect(error).toEqual(serverError(new Error())) 58 | }) 59 | 60 | test('Should return 204 on success', async () => { 61 | const httpResponse = await sut.handle(fakeRequest) 62 | expect(httpResponse).toEqual(noContent()) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey/add-survey/add-survey.ts: -------------------------------------------------------------------------------- 1 | import { AddSurvey } from '@domain/usecases/add-survey' 2 | import { badRequest, noContent, serverError } from '@presentation/helpers/http/http' 3 | import { Controller, HttpResponse, Validation } from '@presentation/interfaces' 4 | 5 | export class AddSurveyController implements Controller { 6 | constructor(private readonly validation: Validation, private readonly addSurvey: AddSurvey) {} 7 | 8 | async handle(request: AddSurveyController.Request): Promise { 9 | try { 10 | const error = this.validation.validate(request) 11 | if (error) return badRequest(error) 12 | 13 | await this.addSurvey.add({ 14 | date: new Date(), 15 | question: request.question, 16 | answers: request.answers 17 | }) 18 | return noContent() 19 | } catch (error) { 20 | return serverError(error) 21 | } 22 | } 23 | } 24 | 25 | export namespace AddSurveyController { 26 | interface Answer { 27 | image?: string 28 | answer: string 29 | } 30 | 31 | export interface Request { 32 | question: string 33 | answers: Answer[] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey/load-survey/load-survey-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import MockDate from 'mockdate' 2 | import { fakeSurveysModel } from '../../../../domain/mocks/mock-survey' 3 | import { SurveyModel } from '../../../../domain/models/survey' 4 | import { LoadSurvey } from '../../../../domain/usecases/load-survey' 5 | import { noContent, ok, serverError } from '../../../helpers/http/http' 6 | import { LoadSurveyController } from './load-survey-controller' 7 | 8 | class MockLoadSurvey implements LoadSurvey { 9 | async load(): Promise { 10 | return new Promise(resolve => resolve(fakeSurveysModel)) 11 | } 12 | } 13 | 14 | describe('LoadSurvey Controller', () => { 15 | const mockLoadSurvey = new MockLoadSurvey() as jest.Mocked 16 | const sut = new LoadSurveyController(mockLoadSurvey) 17 | const fakeRequest = { accountId: 'any_id' } 18 | 19 | beforeAll(() => MockDate.set(new Date())) 20 | afterAll(() => MockDate.reset) 21 | 22 | test('Should call LoadSurvey with correct values', async () => { 23 | const loadSpy = jest.spyOn(mockLoadSurvey, 'load') 24 | await sut.handle(fakeRequest) 25 | expect(loadSpy).toHaveBeenCalledWith(fakeRequest.accountId) 26 | }) 27 | 28 | test('Should return 200 with surveys data on success', async () => { 29 | const httpResponse = await sut.handle(fakeRequest) 30 | expect(httpResponse).toEqual(ok(fakeSurveysModel)) 31 | }) 32 | 33 | test('Should return 500 if loadSurvey throws', async () => { 34 | mockLoadSurvey.load.mockRejectedValueOnce(new Error()) 35 | const error = await sut.handle(fakeRequest) 36 | expect(error).toEqual(serverError(new Error())) 37 | }) 38 | 39 | test('Should return 204 if loadSurvey returns empty', async () => { 40 | mockLoadSurvey.load.mockResolvedValueOnce([]) 41 | const httpResponse = await sut.handle(fakeRequest) 42 | expect(httpResponse).toEqual(noContent()) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/presentation/controllers/survey/load-survey/load-survey-controller.ts: -------------------------------------------------------------------------------- 1 | import { LoadSurvey } from '@domain/usecases/load-survey' 2 | import { noContent, ok, serverError } from '@presentation/helpers/http/http' 3 | import { Controller, HttpResponse } from '@presentation/interfaces' 4 | 5 | export class LoadSurveyController implements Controller { 6 | constructor(private readonly loadSurvey: LoadSurvey) {} 7 | 8 | async handle(request: LoadSurveyController.Request): Promise { 9 | try { 10 | const surveys = await this.loadSurvey.load(request.accountId) 11 | return surveys.length ? ok(surveys) : noContent() 12 | } catch (error) { 13 | return serverError(error) 14 | } 15 | } 16 | } 17 | 18 | export namespace LoadSurveyController { 19 | export interface Request { 20 | accountId: string 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/presentation/errors/access-denied.ts: -------------------------------------------------------------------------------- 1 | export class AccessDeniedError extends Error { 2 | constructor() { 3 | super('Access Denied') 4 | this.name = 'AccessDeniedError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/presentation/errors/email-already-in-use.ts: -------------------------------------------------------------------------------- 1 | export class EmailAlreadyInUseError extends Error { 2 | constructor() { 3 | super('The received email is already in use') 4 | this.name = 'EmailAlreadyInUseError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/presentation/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './server' 2 | export * from './unauthorized' 3 | export * from './missing-param' 4 | export * from './invalid-param' 5 | export * from './email-already-in-use' 6 | -------------------------------------------------------------------------------- /src/presentation/errors/invalid-param.ts: -------------------------------------------------------------------------------- 1 | export class InvalidParamError extends Error { 2 | constructor(paramName: string) { 3 | super(`Invalid param: ${paramName}`) 4 | this.name = paramName 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/presentation/errors/missing-param.ts: -------------------------------------------------------------------------------- 1 | export class MissingParamError extends Error { 2 | constructor(paramName: string) { 3 | super(`Missing param: ${paramName}`) 4 | this.name = paramName 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/presentation/errors/server.ts: -------------------------------------------------------------------------------- 1 | export class ServerError extends Error { 2 | constructor(stack?: string) { 3 | super('Internal server error') 4 | this.name = 'ServerError' 5 | this.stack = stack 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/presentation/errors/unauthorized.ts: -------------------------------------------------------------------------------- 1 | export class UnauthorizedError extends Error { 2 | constructor() { 3 | super('Unauthorized error') 4 | this.name = 'UnauthorizedError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/presentation/helpers/http/http.ts: -------------------------------------------------------------------------------- 1 | import { ServerError, UnauthorizedError } from '@presentation/errors/index' 2 | import { HttpResponse } from '@presentation/interfaces/http' 3 | 4 | export const forbidden = (error: Error): HttpResponse => ({ 5 | statusCode: 403, 6 | body: error 7 | }) 8 | 9 | export const badRequest = (error: Error): HttpResponse => ({ 10 | statusCode: 400, 11 | body: error 12 | }) 13 | 14 | export const unauthorized = (): HttpResponse => ({ 15 | statusCode: 401, 16 | body: new UnauthorizedError() 17 | }) 18 | 19 | export const serverError = (error: Error): HttpResponse => ({ 20 | statusCode: 500, 21 | body: new ServerError(error.stack) 22 | }) 23 | 24 | export const ok = (data: any): HttpResponse => ({ 25 | statusCode: 200, 26 | body: data 27 | }) 28 | 29 | export const noContent = (): HttpResponse => ({ 30 | statusCode: 204, 31 | body: null 32 | }) 33 | -------------------------------------------------------------------------------- /src/presentation/interfaces/controller.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse } from './http' 2 | 3 | export interface Controller { 4 | handle(request: T): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/presentation/interfaces/http.ts: -------------------------------------------------------------------------------- 1 | export interface HttpResponse { 2 | statusCode: number 3 | body: T 4 | } 5 | -------------------------------------------------------------------------------- /src/presentation/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http' 2 | export * from './controller' 3 | export * from './validation' 4 | -------------------------------------------------------------------------------- /src/presentation/interfaces/middleware.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest, HttpResponse } from './http' 2 | 3 | export interface Middleware { 4 | handle(httpRequest: HttpRequest): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/presentation/interfaces/validation.ts: -------------------------------------------------------------------------------- 1 | export interface Validation { 2 | validate(input: any): Error 3 | } 4 | -------------------------------------------------------------------------------- /src/presentation/middlewares/auth-middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAccountModel } from '../../domain/mocks/mock-account' 2 | import { AccountModel } from '../../domain/models/account' 3 | import { LoadAccountByToken } from '../../domain/usecases/load-account-by-token' 4 | import { AccessDeniedError } from '../errors/access-denied' 5 | import { forbidden, ok, serverError } from '../helpers/http/http' 6 | import { AuthMiddleware } from './auth-middleware' 7 | 8 | const fakeRequest: AuthMiddleware.Request = { accessToken: 'any_token' } 9 | 10 | class MockLoadAccountByToken implements LoadAccountByToken { 11 | load(accessToken: string): Promise { 12 | return new Promise(resolve => resolve(fakeAccountModel)) 13 | } 14 | } 15 | 16 | describe('Auth Middleware', () => { 17 | const mockLoadAccountByToken = new MockLoadAccountByToken() as jest.Mocked 18 | const role = 'any_role' 19 | const sut = new AuthMiddleware(mockLoadAccountByToken, role) 20 | 21 | test('Should return 403 if no accessToken exists in request header', async () => { 22 | const httpResponse = await sut.handle({}) 23 | expect(httpResponse).toEqual(forbidden(new AccessDeniedError())) 24 | }) 25 | 26 | test('Should call LoadAccountByToken with correct accessToken', async () => { 27 | const loadSpy = jest.spyOn(mockLoadAccountByToken, 'load') 28 | await sut.handle(fakeRequest) 29 | expect(loadSpy).toHaveBeenCalledWith(fakeRequest.accessToken, role) 30 | }) 31 | 32 | test('Should return 403 if LoadAccountByToken returns null', async () => { 33 | mockLoadAccountByToken.load.mockResolvedValueOnce(null) 34 | const httpResponse = await sut.handle(fakeRequest) 35 | expect(httpResponse).toEqual(forbidden(new AccessDeniedError())) 36 | }) 37 | 38 | test('Should return 200 if LoadAccountByToken returns an account', async () => { 39 | const httpResponse = await sut.handle(fakeRequest) 40 | expect(httpResponse).toEqual(ok({ accountId: 'any_id' })) 41 | }) 42 | 43 | test('Should return 500 if LoadAccountByToken throws', async () => { 44 | mockLoadAccountByToken.load.mockRejectedValueOnce(new Error()) 45 | const httpResponse = await sut.handle(fakeRequest) 46 | expect(httpResponse).toEqual(serverError(new Error())) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/presentation/middlewares/auth-middleware.ts: -------------------------------------------------------------------------------- 1 | import { AccessDeniedError } from '@presentation/errors/access-denied' 2 | import { HttpResponse } from '@presentation/interfaces' 3 | import { Middleware } from '../interfaces/middleware' 4 | import { forbidden, ok, serverError } from '../helpers/http/http' 5 | import { LoadAccountByToken } from '@domain/usecases/load-account-by-token' 6 | 7 | export class AuthMiddleware implements Middleware { 8 | constructor( 9 | private readonly loadAccountByToken: LoadAccountByToken, 10 | private readonly role?: string 11 | ) {} 12 | 13 | async handle(request: AuthMiddleware.Request): Promise { 14 | try { 15 | if (!request.accessToken) return forbidden(new AccessDeniedError()) 16 | const { accessToken } = request 17 | 18 | if (accessToken) { 19 | const account = await this.loadAccountByToken.load(accessToken, this.role) 20 | if (account) return ok({ accountId: account.id }) 21 | } 22 | 23 | return forbidden(new AccessDeniedError()) 24 | } catch (error) { 25 | return serverError(error) 26 | } 27 | } 28 | } 29 | 30 | export namespace AuthMiddleware { 31 | export interface Request { 32 | accessToken?: string 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/validation/interfaces/email-validator.ts: -------------------------------------------------------------------------------- 1 | export interface EmailValidator { 2 | isValid(email: string): boolean 3 | } 4 | -------------------------------------------------------------------------------- /src/validation/validator/compare-fields-validation.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../../presentation/interfaces/validation' 2 | import { InvalidParamError } from '../../presentation/errors' 3 | 4 | export class CompareFieldsValidation implements Validation { 5 | constructor( 6 | private readonly fieldName: string, 7 | private readonly filedToCompare: string 8 | ) {} 9 | 10 | validate(input: any): Error { 11 | if (input[this.fieldName] !== input[this.filedToCompare]) { 12 | return new InvalidParamError(this.filedToCompare) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/validation/validator/email-validation.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../../presentation/interfaces/validation' 2 | import { InvalidParamError } from '../../presentation/errors' 3 | import { EmailValidator } from '../interfaces/email-validator' 4 | 5 | export class EmailValidation implements Validation { 6 | constructor( 7 | private readonly fieldName: string, 8 | private readonly emailValidator: EmailValidator 9 | ) {} 10 | 11 | validate(input: any): Error { 12 | const isValidEmail = this.emailValidator.isValid(input[this.fieldName]) 13 | if (!isValidEmail) return new InvalidParamError(this.fieldName) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/validation/validator/required-fields-validation.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../../presentation/interfaces/validation' 2 | import { MissingParamError } from '../../presentation/errors' 3 | 4 | export class RequiredFieldValidation implements Validation { 5 | constructor(private readonly fieldName: string) {} 6 | 7 | validate(input: any): Error { 8 | if (!input[this.fieldName]) return new MissingParamError(this.fieldName) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/validation/validator/tests/compare-fields-validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidParamError } from '../../../presentation/errors' 2 | import { CompareFieldsValidation } from '../compare-fields-validation' 3 | 4 | describe('CompareFields Validation', () => { 5 | const sut = new CompareFieldsValidation('field', 'fieldToCompare') 6 | 7 | test('Should return an InvalidParamError if validation fails', () => { 8 | const error = sut.validate({ field: 'any_field', fieldToCompare: 'wrong_field' }) 9 | expect(error).toEqual(new InvalidParamError('fieldToCompare')) 10 | }) 11 | 12 | test('Should not return if validation succeeds', () => { 13 | const error = sut.validate({ field: 'any_field', fieldToCompare: 'any_field' }) 14 | expect(error).toBeFalsy() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/validation/validator/tests/email-validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidParamError } from '../../../presentation/errors' 2 | import { EmailValidator } from '../../interfaces/email-validator' 3 | import { EmailValidation } from '../email-validation' 4 | 5 | class MockEmailValidation implements EmailValidator { 6 | isValid(email: string): boolean { 7 | return true 8 | } 9 | } 10 | 11 | describe('EmailValidation', () => { 12 | const mockEmailValidation = new MockEmailValidation() as jest.Mocked 13 | const sut = new EmailValidation('email', mockEmailValidation) 14 | 15 | test('Should return an error if EmailValidator returns false', () => { 16 | jest.spyOn(mockEmailValidation, 'isValid').mockReturnValueOnce(false) 17 | const error = sut.validate({ email: 'gabriel@example.com' }) 18 | expect(error).toEqual(new InvalidParamError('email')) 19 | }) 20 | 21 | test('Should call EmailValidator with correct email', () => { 22 | const isValidSpy = jest.spyOn(mockEmailValidation, 'isValid') 23 | sut.validate({ email: 'gabriel@example.com' }) 24 | expect(isValidSpy).toHaveBeenCalledWith('gabriel@example.com') 25 | }) 26 | 27 | test('Should throw if EmailValidator throws', () => { 28 | jest.spyOn(mockEmailValidation, 'isValid').mockImplementationOnce(() => { 29 | throw new Error() 30 | }) 31 | expect(sut.validate).toThrow() 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/validation/validator/tests/required-field-validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { MissingParamError } from '../../../presentation/errors' 2 | import { RequiredFieldValidation } from '../required-fields-validation' 3 | 4 | describe('RequiredField Validation', () => { 5 | const sut = new RequiredFieldValidation('field') 6 | 7 | test('Should return a MissingParamError if validation fails', () => { 8 | const error = sut.validate({ name: 'any_name' }) 9 | expect(error).toEqual(new MissingParamError('field')) 10 | }) 11 | 12 | test('Should not return if validation succeeds', () => { 13 | const error = sut.validate({ field: 'any_field' }) 14 | expect(error).toBeFalsy() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/validation/validator/tests/validation-composite.spec.ts: -------------------------------------------------------------------------------- 1 | import { MissingParamError } from '../../../presentation/errors/' 2 | import { Validation } from '../../../presentation/interfaces/validation' 3 | import { ValidationComposite } from '../validation-composite' 4 | 5 | class ValidationStub implements Validation { 6 | validate(input: any): Error { 7 | return null 8 | } 9 | } 10 | 11 | const makeSut = () => { 12 | const validationStubs = [new ValidationStub(), new ValidationStub()] 13 | const sut = new ValidationComposite(validationStubs) 14 | return { sut, validationStubs } 15 | } 16 | 17 | describe('Validation Composite', () => { 18 | test('Should return an error if any validation fails', () => { 19 | const { sut, validationStubs } = makeSut() 20 | jest.spyOn(validationStubs[0], 'validate').mockReturnValueOnce(new MissingParamError('field')) 21 | const error = sut.validate({ field: 'any_field' }) 22 | expect(error).toEqual(new MissingParamError('field')) 23 | }) 24 | 25 | test('Should return the first error if more than one validation fails', () => { 26 | const { sut, validationStubs } = makeSut() 27 | 28 | jest.spyOn(validationStubs[0], 'validate').mockReturnValueOnce(new Error()) 29 | jest.spyOn(validationStubs[1], 'validate').mockReturnValueOnce(new MissingParamError('field')) 30 | 31 | const error = sut.validate({ field: 'any_field' }) 32 | expect(error).toEqual(new Error()) 33 | }) 34 | 35 | test('Should not return if validation succeeds', () => { 36 | const { sut } = makeSut() 37 | const error = sut.validate({ field: 'any_field' }) 38 | expect(error).toBeFalsy() 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/validation/validator/validation-composite.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../../presentation/interfaces/validation' 2 | 3 | export class ValidationComposite implements Validation { 4 | constructor(private readonly validations: Validation[]) {} 5 | 6 | validate(input: any): Error { 7 | for (const validation of this.validations) { 8 | const error = validation.validate(input) 9 | if (error) return error 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "lib": ["es6"], 6 | "allowJs": true, 7 | "outDir": "./dist", 8 | "rootDir": "./", 9 | "sourceMap": true, 10 | "removeComments": true, 11 | "typeRoots": ["./node_modules/@types", "./src/@types"], 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "@data/*": ["./src/data/*"], 21 | "@main/*": ["./src/main/*"], 22 | "@presentation/*": ["./src/presentation/*"], 23 | "@domain/*": ["./src/domain/*"], 24 | "@infra/*": ["./src/infra/*"] 25 | } 26 | }, 27 | "include": ["src"], 28 | "exclude": ["**/*.spec.ts", "**/*.test.ts"] 29 | } 30 | --------------------------------------------------------------------------------