├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── dependabot-automerge.yml │ ├── dockerize.yml │ ├── e2e.yml │ ├── greetings.yml │ └── stale.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .mergify.yml ├── .prettierrc ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── codecov.yml ├── commitlint.config.js ├── docker-compose.yml ├── docs ├── _config.yml ├── api.md ├── auth.md ├── config.md ├── guide.md ├── index.md ├── model.md ├── mongo.md ├── testing.md └── user.md ├── eslint.config.mjs ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.spec.ts ├── app.service.ts ├── auth │ ├── auth.constants.ts │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── guard │ │ ├── has-roles.decorator.ts │ │ ├── jwt-auth.guard.spec.ts │ │ ├── jwt-auth.guard.ts │ │ ├── local-auth.guard.spec.ts │ │ ├── local-auth.guard.ts │ │ ├── roles.guard.spec.ts │ │ └── roles.guard.ts │ ├── interface │ │ ├── access-token.interface.ts │ │ ├── authenticated-request.interface.ts │ │ ├── jwt-payload.interface.ts │ │ └── user-principal.interface.ts │ └── strategy │ │ ├── jwt.strategy.spec.ts │ │ ├── jwt.strategy.ts │ │ ├── local.strategy.spec.ts │ │ └── local.strategy.ts ├── config │ ├── jwt.config.spec.ts │ ├── jwt.config.ts │ ├── mongodb.config.spec.ts │ ├── mongodb.config.ts │ ├── sendgrid.config.spec.ts │ └── sendgrid.config.ts ├── database │ ├── comment.model.ts │ ├── database-connection.providers.spec.ts │ ├── database-connection.providers.ts │ ├── database-models.providers.spec.ts │ ├── database-models.providers.ts │ ├── database.constants.ts │ ├── database.module.ts │ ├── post.model.ts │ ├── user.mdoel.spec.ts │ └── user.model.ts ├── logger │ ├── logger.decorator.ts │ ├── logger.module.ts │ ├── logger.providers.ts │ ├── logger.service.spec.ts │ └── logger.service.ts ├── main.ts ├── post │ ├── create-comment.dto.ts │ ├── create-post.dto.ts │ ├── post-data-initializer.service.ts │ ├── post.controller.spec.ts │ ├── post.controller.ts │ ├── post.module.ts │ ├── post.service.spec.ts │ ├── post.service.stub.ts │ ├── post.service.ts │ └── update-post.dto.ts ├── sendgrid │ ├── sendgrid.constants.ts │ ├── sendgrid.module.ts │ ├── sendgrid.providers.spec.ts │ ├── sendgrid.providers.ts │ ├── sendgrid.service.spec.ts │ └── sendgrid.service.ts ├── shared │ ├── enum │ │ └── role-type.enum.ts │ └── pipe │ │ ├── parse-object-id.pipe.spec.ts │ │ └── parse-object-id.pipe.ts └── user │ ├── profile.controller.spec.ts │ ├── profile.controller.ts │ ├── register.controller.spec.ts │ ├── register.controller.ts │ ├── register.dto.spec.ts │ ├── register.dto.ts │ ├── user-data-initializer.service.ts │ ├── user.controller.spec.ts │ ├── user.controller.ts │ ├── user.dto.ts │ ├── user.module.ts │ ├── user.service.spec.ts │ └── user.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: 'hantsy' 7 | 8 | --- 9 | 10 | **Development Environment:** 11 | - OS: [e.g. Windows 10, Linux] 12 | - NodeJS version:[8, 11] 13 | - Package manager tools:[npm, yarn] 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **To Reproduce** 19 | Steps to reproduce the behavior: 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: npm 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | # schedule: 12 | # interval: "daily" 13 | # time: "21:00" 14 | open-pull-requests-limit: 10 15 | # Increase the version requirements 16 | # only when required 17 | versioning-strategy: increase-if-necessary 18 | #reviewers: 19 | # - "hantsy" 20 | #assignees: 21 | # - "hantsy" 22 | labels: 23 | - "dependencies" 24 | - "npm" 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | branches: 8 | - master 9 | # - release/* 10 | pull_request: 11 | types: 12 | - opened 13 | - synchronize 14 | - reopened 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Setup NodeJS 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '22' 26 | 27 | - name: Cache Node.js modules 28 | uses: actions/cache@v4 29 | with: 30 | # npm cache files are stored in `~/.npm` on Linux/macOS 31 | path: ~/.npm 32 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.OS }}-node- 35 | ${{ runner.OS }}- 36 | 37 | # install dependencies and build the project 38 | - run: npm install --registry=https://registry.npmjs.org 39 | - run: npm run test:cov 40 | - name: Upload testing reports 41 | run: | 42 | bash <(curl -s https://codecov.io/bash) 43 | env: 44 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 45 | # see: https://www.edwardthomson.com/blog/github_actions_22_automerge_security_updates.html 46 | # https://localheinz.com/blog/2020/06/15/merging-pull-requests-with-github-actions/ 47 | # automerge: 48 | # name: Merge pull request 49 | # runs-on: ubuntu-latest 50 | # needs: [build] 51 | # if: > 52 | # github.event_name == 'pull_request' && 53 | # github.event.pull_request.draft == false && ( 54 | # github.event.action == 'opened' || 55 | # github.event.action == 'reopened' || 56 | # github.event.action == 'synchronize' 57 | # ) && ( 58 | # github.actor == 'dependabot[bot]' 59 | # ) 60 | 61 | # steps: 62 | # - name: "Merge pull request" 63 | # uses: "actions/github-script@v2" 64 | # with: 65 | # github-token: "${{ secrets.GITHUB_TOKEN }}" 66 | # script: | 67 | # const pullRequest = context.payload.pull_request 68 | # const repository = context.repo 69 | 70 | # await github.pulls.merge({ 71 | # merge_method: "merge", 72 | # owner: repository.owner, 73 | # pull_number: pullRequest.number, 74 | # repo: repository.repo, 75 | # }) 76 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: 3 | pull_request_target: 4 | workflow_run: 5 | workflows: ["build"] 6 | types: 7 | - completed 8 | permissions: 9 | pull-requests: write 10 | contents: write 11 | 12 | jobs: 13 | dependabot: 14 | runs-on: ubuntu-latest 15 | if: ${{ github.actor == 'dependabot[bot]' }} && ${{ github.event.workflow_run.conclusion == 'success' }} 16 | steps: 17 | - name: Dependabot metadata 18 | id: metadata 19 | uses: dependabot/fetch-metadata@v2.4.0 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | - name: Enable auto-merge for Dependabot PRs 23 | # if: ${{contains(steps.metadata.outputs.dependency-names, 'my-dependency') && steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 24 | run: gh pr merge --auto --merge "$PR_URL" 25 | env: 26 | PR_URL: ${{github.event.pull_request.html_url}} 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/dockerize.yml: -------------------------------------------------------------------------------- 1 | name: Dockerize 2 | 3 | on: 4 | pull_request_target: 5 | workflow_run: 6 | workflows: ["build"] 7 | types: 8 | - completed 9 | 10 | jobs: 11 | on-success: 12 | runs-on: ubuntu-latest 13 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Setup NodeJS 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: "18" 21 | 22 | - name: Cache Node.js modules 23 | uses: actions/cache@v4 24 | with: 25 | # npm cache files are stored in `~/.npm` on Linux/macOS 26 | path: ~/.npm 27 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | ${{ runner.OS }}-node- 30 | ${{ runner.OS }}- 31 | 32 | # install dependencies and build the project 33 | - run: npx husky-init && npm install --registry=https://registry.npmjs.org 34 | - run: npm run build --if-present 35 | 36 | # build docker image. 37 | - name: Build Docker Image 38 | run: | 39 | docker build -t hantsy/nest-sample . 40 | - name: Login to DockerHub Registry 41 | run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin 42 | - name: Push Docker Image 43 | run: docker push hantsy/nest-sample 44 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | branches: 8 | - master 9 | - release/* 10 | pull_request: 11 | types: 12 | - opened 13 | - synchronize 14 | - reopened 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Setup NodeJS 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '22' 26 | 27 | - name: Cache Node.js modules 28 | uses: actions/cache@v4 29 | with: 30 | # npm cache files are stored in `~/.npm` on Linux/macOS 31 | path: ~/.npm 32 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.OS }}-node- 35 | ${{ runner.OS }}- 36 | - run: npm install --registry=https://registry.npmjs.org 37 | - name: Running mongodb service 38 | run: | 39 | docker compose up -d mongodb 40 | docker ps -a 41 | 42 | # install dependencies and build the project 43 | - name: Running e2e testing 44 | run: | 45 | export SENDGRID_API_KEY=${{ secrets.SENDGRID_API_KEY }} 46 | npm run test:e2e -- --runInBand --forceExit 47 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/first-interaction@v1 10 | with: 11 | repo-token: ${{ secrets.GITHUB_TOKEN }} 12 | issue-message: | 13 | Hello @${{ github.actor }}, thank you for your interest in this repo! Please follow the **Bug Report** template to fill out the steps to reproduce the issues. Thanks again. 14 | pr-message: "Hello @${{ github.actor }}, thanks for sending a PR. Please be patient, I will review it as soon as possible." 15 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/stale@v9 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | stale-issue-message: "Marked as stale" 16 | stale-pr-message: "Marked as stale" 17 | stale-issue-label: "no-issue-activity" 18 | stale-pr-label: "no-pr-activity" 19 | days-before-stale: 90 20 | days-before-close: 15 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | .env 37 | .metals 38 | .vscode 39 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown 2 | git update-index --again 3 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: Automatic merge on approval 3 | conditions: 4 | - "#approved-reviews-by>=1" 5 | - author~=^dependabot(|-preview)\[bot\]$ 6 | actions: 7 | merge: 8 | method: merge 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "type": "node", 10 | "name": "vscode-jest-tests", 11 | "request": "launch", 12 | "args": [ 13 | "--runInBand" 14 | ], 15 | "cwd": "${workspaceFolder}", 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen", 18 | "disableOptimisticBPs": true, 19 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 20 | }, 21 | { 22 | "type": "node", 23 | "request": "launch", 24 | "name": "Launch Program", 25 | "skipFiles": [ 26 | "/**" 27 | ], 28 | "program": "${workspaceFolder}\\start", 29 | "preLaunchTask": "tsc: build - tsconfig.json", 30 | "outFiles": [ 31 | "${workspaceFolder}/dist/**/*.js" 32 | ] 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Set nginx base image 2 | FROM node:18 3 | LABEL maintainer="Hantsy Bai" 4 | WORKDIR /app 5 | COPY ./dist ./dist 6 | COPY package.json . 7 | COPY package-lock.json . 8 | RUN npm ci --only=production --ignore-scripts 9 | EXPOSE 3000 10 | CMD ["node", "dist/main"] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS Sample 2 | 3 | ![Compile and build](https://github.com/hantsy/nestjs-sample/actions/workflows/build.yml/badge.svg) 4 | ![Build Docker Image](https://github.com/hantsy/nestjs-sample/workflows/Dockerize/badge.svg) 5 | ![Run e2e testing](https://github.com/hantsy/nestjs-sample/workflows/e2e/badge.svg) 6 | [![codecov](https://codecov.io/gh/hantsy/nestjs-rest-sample/branch/master/graph/badge.svg?token=MBLWAJPG13)](https://codecov.io/gh/hantsy/nestjs-rest-sample) 7 | 8 | A NestJS RESTful APIs sample project, including: 9 | 10 | * Restful APIs satisfies Richardson Maturity Model(Level 2) 11 | * Custom Mongoose integration module instead of @nestjs/mongoose 12 | * Passport/Jwt authentication with simple text secrets 13 | * Fully testing codes with Jest, jest-mock-extended, ts-mockito, @golevelup/ts-jest etc. 14 | * Github actions workflow for continuous testing, code coverage report, docker image building, etc. 15 | 16 | ## Docs 17 | 18 | * [Getting Started](./docs/guide.md) 19 | * [Connecting to MongoDB](./docs/mongo.md) 20 | * [Protect your APIs with JWT Token](./docs/auth.md) 21 | * [Dealing with model relations](./docs/model.md) 22 | * [Externalizing the configuration](./docs/config.md) 23 | * [Handling user registration](./docs/user.md) 24 | * [Testing Nestjs applications](./docs/testing.md) 25 | 26 | ## Build 27 | 28 | Install the dependencies. 29 | 30 | ```bash 31 | $ npm install 32 | ``` 33 | 34 | Running the app 35 | 36 | ```bash 37 | # development 38 | $ npm run start 39 | 40 | # watch mode 41 | $ npm run start:dev 42 | 43 | # production mode 44 | $ npm run start:prod 45 | ``` 46 | 47 | Test 48 | 49 | ```bash 50 | # unit tests 51 | $ npm run test 52 | 53 | # e2e tests 54 | $ npm run test:e2e 55 | 56 | # test coverage 57 | $ npm run test:cov 58 | ``` 59 | 60 | 61 | ## Reference 62 | 63 | * [The official Nestjs documentation](https://docs.nestjs.com/first-steps) 64 | * [Unit testing NestJS applications with Jest](https://blog.logrocket.com/unit-testing-nestjs-applications-with-jest/) 65 | * [ts-mockito: Mocking library for TypeScript inspired by http://mockito.org/](https://github.com/NagRock/ts-mockito) 66 | * [Clock-in/out System Series](https://carloscaballero.io/part-2-clock-in-out-system-basic-backend/) 67 | * [Modern Full-Stack Development with Nest.js, React, TypeScript, and MongoDB: Part 1](https://auth0.com/blog/modern-full-stack-development-with-nestjs-react-typescript-and-mongodb-part-1/), [Part 2](https://auth0.com/blog/modern-full-stack-development-with-nestjs-react-typescript-and-mongodb-part-2/) 68 | * [Code with Hugo - Jest Full and Partial Mock/Spy of CommonJS and ES6 Module Imports](https://codewithhugo.com/jest-mock-spy-module-import/) 69 | * There is a collection of courses from [https://wanago.io/](https://wanago.io/) which is very helpful for building applications with NestJS: 70 | * [API with NestJS](https://wanago.io/courses/api-with-nestjs/) 71 | * [Series: TypeScript Express tutorial](https://wanago.io/courses/typescript-express-tutorial/) 72 | * [Series: Node.js TypeScript](https://wanago.io/courses/node-js-typescript/) 73 | * [Series: JavaScript testing tutorial](https://wanago.io/courses/javascript-testing-tutorial/) 74 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "**/*stub.ts" 3 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' # specify docker-compose version 2 | 3 | # Define the services/containers to be run 4 | services: 5 | 6 | mongodb: 7 | image: mongo 8 | volumes: 9 | - mongodata:/data/db 10 | ports: 11 | - "27017:27017" 12 | networks: 13 | - backend 14 | 15 | volumes: 16 | mongodata: 17 | 18 | networks: 19 | backend: -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman 2 | title: Building RESTful APIs with NestJS 3 | description: Step-by-step guide of creating RESTful APIs using NestJS and Typescript 4 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hantsy/nestjs-rest-sample/2241e81ccba03ab4f9887b1d5a71db6eb13c90cd/docs/api.md -------------------------------------------------------------------------------- /docs/auth.md: -------------------------------------------------------------------------------- 1 | # Protect your APIs with JWT Token 2 | 3 | In the last post, we connected to a Mongo server and used a real database to replace the dummy data storage. In this post, we will explore how to protect your APIs when exposing to a client application. 4 | 5 | When we come to the security of a web application, technically it will include: 6 | 7 | * **Authentication** - The application will ask you to provide your principal and then it will identify who are you. 8 | * **Authorization ** - Based on your claims, check if you have permissions to perform some operations. 9 | 10 | [Passportjs](http://www.passportjs.org/) is one of the most popular authentication frameworks on the [Expressjs](https://expressjs.com/) platform. Nestjs has great integration with passportjs with its `@nestjs/passportjs` module. We will follow the [Authentication](https://docs.nestjs.com/techniques/authentication) chapter of the official guide to add *local* and *jwt* strategies to the application we have done the previous posts. 11 | 12 | ## Prerequisites 13 | 14 | Install passportjs related dependencies. 15 | 16 | ```bash 17 | $ npm install --save @nestjs/passport passport passport-local @nestjs/jwt passport-jwt 18 | $ npm install --save-dev @types/passport-local @types/passport-jwt 19 | ``` 20 | 21 | Before starting the authentication work, let's generate some skeleton codes. 22 | 23 | Firstly generate a `AuthModule` and `AuthService` . 24 | 25 | ```bash 26 | nest g mo auth 27 | nest g s auth 28 | ``` 29 | The authentication should work with users in the application. 30 | 31 | Similarly, create a standalone `UserModule` to handle user queries. 32 | 33 | ```bash 34 | nest g mo user 35 | nest g s user 36 | ``` 37 | Ok, let's begin to enrich the `AuthModule`. 38 | 39 | ## Implementing Authentication 40 | 41 | First of all, let's create some resources for the user model, a `Document` and `Schema` file. 42 | 43 | Create new file under */user* folder. 44 | 45 | ```typescript 46 | import { SchemaFactory, Schema, Prop } from '@nestjs/mongoose'; 47 | import { Document } from 'mongoose'; 48 | 49 | @Schema() 50 | export class User extends Document { 51 | @Prop({ require: true }) 52 | readonly username: string; 53 | 54 | @Prop({ require: true }) 55 | readonly email: string; 56 | 57 | @Prop({ require: true }) 58 | readonly password: string; 59 | } 60 | 61 | export const UserSchema = SchemaFactory.createForClass(User); 62 | ``` 63 | The `User` class is to wrap a document in Mongoose, and `UserSchema` is to describe `User` document. 64 | 65 | Register `UserSchema` in `UserModule`, then you can use `Model` to perform some operations on `User` document. 66 | 67 | ```typescript 68 | @Module({ 69 | imports: [MongooseModule.forFeature([{ name: 'users', schema: UserSchema }])], 70 | providers: [//... 71 | ], 72 | exports: [//... 73 | ], 74 | }) 75 | export class UserModule {} 76 | ``` 77 | 78 | The *users* here is used as the *token* to identify different `Model` when injecting a `Model`. When registering a `UserSchema` in mongoose, the name attribute in the above `MongooseModule.forFeature` is also the collection name of `User ` documents. 79 | 80 | Add a `findByUsername` method in `UserService`. 81 | 82 | ```typescript 83 | @Injectable() 84 | export class UserService { 85 | constructor(@InjectModel('users') private userModel: Model) {} 86 | 87 | findByUsername(username: string): Observable { 88 | return from(this.userModel.findOne({ username }).exec()); 89 | } 90 | } 91 | ``` 92 | 93 | In the `@Module` declaration of the `UserModule`, register `UserService` in `providers`, and do not forget to add it into `exports`, thus other modules can use this service when importing `UserModule`. 94 | 95 | ```typescript 96 | //...other imports 97 | import { UserService } from './user.service'; 98 | 99 | @Module({ 100 | providers: [UserService], 101 | exports:[UserService]//exposing users to other modules... 102 | }) 103 | export class UserModule {} 104 | ``` 105 | 106 | Create a test case to test the `findByUsername` method. 107 | 108 | ```typescript 109 | describe('UserService', () => { 110 | let service: UserService; 111 | let model: Model; 112 | 113 | beforeEach(async () => { 114 | const module: TestingModule = await Test.createTestingModule({ 115 | providers: [ 116 | UserService, 117 | { 118 | provide: getModelToken('users'), 119 | useValue: { 120 | findOne: jest.fn(), 121 | }, 122 | }, 123 | ], 124 | }).compile(); 125 | 126 | service = module.get(UserService); 127 | model = module.get>(getModelToken('users')); 128 | }); 129 | 130 | it('should be defined', () => { 131 | expect(service).toBeDefined(); 132 | }); 133 | 134 | it('findByUsername should return user', async () => { 135 | jest 136 | .spyOn(model, 'findOne') 137 | .mockImplementation((conditions: any, projection: any, options: any) => { 138 | return { 139 | exec: jest.fn().mockResolvedValue({ 140 | username: 'hantsy', 141 | email: 'hantsy@example.com', 142 | } as User), 143 | } as any; 144 | }); 145 | 146 | const foundUser = await service.findByUsername('hantsy').toPromise(); 147 | expect(foundUser).toEqual({ 148 | username: 'hantsy', 149 | email: 'hantsy@example.com', 150 | }); 151 | expect(model.findOne).lastCalledWith({username: 'hantsy'}); 152 | expect(model.findOne).toBeCalledTimes(1); 153 | }); 154 | }); 155 | ``` 156 | `UserService` depends on a `Model`, use a provider to mock it by jest mocking feature. Using `jest.spyOn` method, you can stub the details of a methods, and watch of the calling of this method. 157 | 158 | Let's move to `AuthModule`. 159 | 160 | With `@nestjs/passpart`, it is simple to set up your passport strategy by extending `PassportStrategy`, we will create two passport strategies here. 161 | 162 | * `LocalStrategy` to handle authentication by username and password fields from request. 163 | * `JwtStrategy` to handle authentication by given JWT token header. 164 | 165 | Simplify , generate two files by nest command line. 166 | 167 | ```bash 168 | nest g class auth/local.strategy.ts --flat 169 | nest g class auth/jwt.strategy.ts --flat 170 | ``` 171 | 172 | Firstly, let's implement the `LocalStrategy`. 173 | 174 | ```typescript 175 | @Injectable() 176 | export class LocalStrategy extends PassportStrategy(Strategy) { 177 | constructor(private authService: AuthService) { 178 | super({ 179 | usernameField: 'username', 180 | passwordField: 'password', 181 | }); 182 | } 183 | 184 | validate(username: string, password: string): Observable { 185 | return this.authService 186 | .validateUser(username, password) 187 | .pipe(throwIfEmpty(() => new UnauthorizedException())); 188 | } 189 | } 190 | ``` 191 | 192 | In the constructor, use `super` to provide the essential options of the strategy you are using. For the local strategy, it requires username and password fields. 193 | 194 | And the validate method is used to validate the authentication info against given info, here it is the *username* and *password* provided from request. 195 | 196 | > More details about the configuration options and validation of local strategy, check [passport-local](http://www.passportjs.org/packages/passport-local/) project. 197 | 198 | In `AuthService`, add a method `validateUser`. 199 | 200 | ```typescript 201 | @Injectable() 202 | export class AuthService { 203 | constructor( 204 | private userService: UserService, 205 | private jwtService: JwtService, 206 | ) {} 207 | 208 | validateUser(username: string, pass: string): Observable { 209 | return this.userService.findByUsername(username).pipe( 210 | map(user => { 211 | if (user && user.password === pass) { 212 | const { password, ...result } = user; 213 | return result; 214 | } 215 | return null; 216 | }), 217 | ); 218 | } 219 | } 220 | ``` 221 | 222 | > In the real application, we could use a crypto util to hash and compare the input password. We will discuss it in the further post. 223 | 224 | It invokes `findByUsername` in `UserService` from `UserModule`. Imports `UserModule` in the declaration of `AuthModule`. 225 | 226 | ```typescript 227 | @Module({ 228 | imports: [ 229 | UserModule, 230 | ...] 231 | ... 232 | }) 233 | export class AuthModule {} 234 | ``` 235 | 236 | Let's create a method in `AppController` to implement the authentication by given username and password fields. 237 | 238 | ```typescript 239 | @Controller() 240 | export class AppController { 241 | constructor(private authService: AuthService) {} 242 | 243 | @UseGuards(LocalAuthGuard) 244 | @Post('auth/login') 245 | login(@Req() req: Request): Observable { 246 | return this.authService.login(req.user); 247 | } 248 | } 249 | ``` 250 | 251 | It simply calls another method `login` in `AuthService`. 252 | 253 | ```typescript 254 | @Injectable() 255 | export class AuthService { 256 | constructor( 257 | private userService: UserService, 258 | private jwtService: JwtService, 259 | ) {} 260 | //... 261 | login(user: Partial): Observable { 262 | const payload = { 263 | sub: user.username, 264 | email: user.email, 265 | roles: user.roles, 266 | }; 267 | return from(this.jwtService.signAsync(payload)).pipe( 268 | map(access_token => { 269 | access_token; 270 | }), 271 | ); 272 | } 273 | } 274 | ``` 275 | 276 | The `login` method is responsible for generating a JWT based access token based on the authenticated principal. 277 | 278 | The URI path `auth/login` use a `LocalAuthGuard` to protect it. 279 | 280 | ```typescript 281 | @Injectable() 282 | export class LocalAuthGuard extends AuthGuard('local') {} 283 | ``` 284 | 285 | Let's summarize how local strategy works. 286 | 287 | 1. When a user hits *auth/login* with `username` and `password`, `LocalAuthGuard` will be applied. 288 | 2. `LocalAuthGuard` will trigger `LocalStrategy` , and invokes its `validate` method, and store the result back to `request.user`. 289 | 3. Back the controller, read user principal from `request`, generate a JWT token and send it back to the client. 290 | 291 | After logging in, the `access token` can be extracted and put into the HTTP header in the new request to access the protected resources. 292 | 293 | Let's have a look at how JWT strategy works. 294 | 295 | Firstly implement the `JwtStrategy`. 296 | 297 | ```typescript 298 | @Injectable() 299 | export class JwtStrategy extends PassportStrategy(Strategy) { 300 | constructor() { 301 | super({ 302 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 303 | ignoreExpiration: false, 304 | secretOrKey: jwtConstants.secret, 305 | }); 306 | } 307 | 308 | validate(payload: any) :any{ 309 | return { email: payload.email, sub: payload.username }; 310 | } 311 | } 312 | ``` 313 | 314 | In the constructor, there are several options configured. 315 | 316 | The `jwtFromRequest` specifies the approach to extract token, it can be from HTTP cookie or request header `Authorization` . 317 | 318 | If `ignoreExpiration` is false, when decoding the JWT token, it will check expiration date. 319 | 320 | The `secretOrKey` is used to sign the JWT token or decode token. 321 | 322 | In the `validate` method, the payload is the content of **decoded** JWT claims. You can add custom validation based on the claims. 323 | 324 | > More about the configuration options and verify method, check [passport-jwt](http://www.passportjs.org/packages/passport-jwt/) project. 325 | 326 | In the declaration of `AuthModule` , imports `JwtModule`, it accept a register method to add initial options for signing the JWT token. 327 | 328 | ```typescript 329 | @Module({ 330 | imports: [ 331 | // ... 332 | JwtModule.register({ 333 | secret: jwtConstants.secret, 334 | signOptions: { expiresIn: '60s' }, 335 | }), 336 | ], 337 | providers: [//..., 338 | LocalStrategy, JwtStrategy], 339 | exports: [AuthService], 340 | }) 341 | export class AuthModule {} 342 | ``` 343 | 344 | Similarly create a `JwtAuthGuard`, and register it in the *providers* in `AuthModule`. 345 | 346 | ```typescript 347 | @Injectable() 348 | export class JwtAuthGuard extends AuthGuard('jwt'){} 349 | ``` 350 | 351 | Create a method to read profile of the current user. 352 | 353 | ```typescript 354 | @Controller() 355 | export class AppController { 356 | constructor(private authService: AuthService) {} 357 | 358 | //... 359 | @UseGuards(JwtAuthGuard) 360 | @Get('profile') 361 | getProfile(@Req() req: Request): any { 362 | return req.user; 363 | } 364 | } 365 | ``` 366 | 367 | Let's review the workflow of the JWT strategy. 368 | 369 | 1. Given a JWT token `XXX`, access */profile* with header `Authorization:Bearer XXX`. 370 | 2. `JwtAuthGuard` will trigger `JwtStrategy`, and calls `validate` method, and store the result back to `request.user`. 371 | 3. In the `getProfile` method, send the `request.user` to client. 372 | 373 | If you want to set a default strategy, change `PassportModule` in the declaration of `AuthModule` to the following. 374 | 375 | ```typescript 376 | @Module({ 377 | imports: [ 378 | PassportModule.register({ defaultStrategy: 'jwt' }), 379 | //... 380 | }) 381 | export class AuthModule {} 382 | ``` 383 | 384 | There are several application lifecycle hooks provided in Nestjs at runtime. In your codes you can observe these lifecycle events and perform some specific tasks for your application. 385 | 386 | For example, create a data initializer for `Post` to insert sample data. 387 | 388 | ```typescript 389 | @Injectable() 390 | export class UserDataInitializerService 391 | implements OnModuleInit, OnModuleDestroy { 392 | constructor(@InjectModel('users') private userModel: Model) {} 393 | onModuleInit(): void { 394 | console.log('(UserModule) is initialized...'); 395 | this.userModel 396 | .create({ 397 | username: 'hantsy', 398 | password: 'password', 399 | email: 'hantsy@example.com', 400 | }) 401 | .then(data => console.log(data)); 402 | } 403 | onModuleDestroy(): void { 404 | console.log('(UserModule) is being destroyed...'); 405 | this.userModel 406 | .deleteMany({}) 407 | .then(del => console.log(`deleted ${del.deletedCount} rows`)); 408 | } 409 | } 410 | ``` 411 | 412 | > More info about the lifecycle hooks, check the [Lifecycle events](https://docs.nestjs.com/fundamentals/lifecycle-events) chapter of the official docs. 413 | 414 | ## Run the application 415 | 416 | Open your terminal, run the application by executing the following command. 417 | 418 | ```bash 419 | npm run start 420 | ``` 421 | 422 | Login using the *username/password* pair. 423 | 424 | ```bash 425 | >curl http://localhost:3000/auth/login -d "{\"username\":\"hantsy\", \"password\":\"password\"}" -H "Content-Type:application/json" 426 | >{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cG4iOiJoYW50c3kiLCJzdWIiOiI1ZjJkMGU0ODZhOTZiZTEyMDBmZWZjZWMiLCJlbWFpbCI6ImhhbnRzeUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTk2Nzg4NDg5LCJleHAiOjE1OTY3OTIwODl9.4oYpKTikoTfeeaUBoEFr9d1LPcN1pYqHjWXRuZXOfek"} 427 | ``` 428 | 429 | Try to access the */profile* endpoint using this *access_token*. 430 | 431 | ```bash 432 | >curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cG4iOiJoYW50c3kiLCJzdWIiOiI1ZjJkMGU0ODZhOTZiZTEyMDBmZWZjZWMiLCJlbWFpbCI6ImhhbnRzeUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTk2Nzg4NDg5LCJleHAiOjE1OTY3OTIwODl9.4oYpKTikoTfeeaUBoEFr9d1LPcN1pYqHjWXRuZXOfek" 433 | {"username":"hantsy","email":"hantsy@example.com","id":"5f2d0e486a96be1200fefcec","roles":["USER"]} 434 | ``` 435 | 436 | Grab [the source codes from my github](https://github.com/hantsy/nestjs-sample), switch to branch [feat/auth](https://github.com/hantsy/nestjs-sample/blob/feat/auth). -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Externalizing the configuration 2 | 3 | Till now, all configurations in our application is working for the local development environment, but they are written in hard codes. 4 | 5 | In the development and deployment stages of a real world application, we have to consider a flexible way to alter the configurations in the production environment without any code changes while the application is deployed continuously through a predefined delivery pipeline. 6 | 7 | Nestjs provides excellent configuration support, thus your application can read the configuration values from environment variables, a *.env* file, etc. More info about the configuration, check the [Configuration ](https://docs.nestjs.com/techniques/configuration) chapter from the Nestjs official docs. 8 | 9 | In this post, we will move our hard-coded configuration we've used in the previous posts to a central place and organize them with the NestJS configuration facilities. 10 | 11 | ## Introduce to ConfigModule 12 | 13 | First of all, install `@nestjs/config` package. 14 | 15 | ```bash 16 | npm install @nestjs/config 17 | ``` 18 | 19 | Simply, import `ConfigModule` in the top-level `AppModule`. 20 | 21 | ```typescript 22 | 23 | import { Module } from '@nestjs/common'; 24 | import { ConfigModule } from '@nestjs/config'; 25 | 26 | @Module({ 27 | imports: [ConfigModule.forRoot()], 28 | }) 29 | export class AppModule {} 30 | ``` 31 | 32 | It will register a `ConfigService` for you to read configuration properties by calling its `get` method. 33 | 34 | ```typescript 35 | configService.get('MONGO_URI') 36 | ``` 37 | 38 | Internally, Nestjs will scan `.env` file in the root folder. 39 | 40 | The following is a sample of the content of the `.env` file. 41 | 42 | ```env 43 | MONGO_URI=mongodb://localhost/blog 44 | ``` 45 | 46 | If you want to read configuration from an environment-aware file, eg. `.dev.env` for development phase, then configure the location in `ConfigModule`. 47 | 48 | ```typescript 49 | ConfigModule.forRoot({ 50 | envFilePath: '.dev.env', 51 | }); 52 | ``` 53 | 54 | For the container deployment or cloud deployment, setup configuration in a config server or as environment variables or K8s *ConfigMap* is more popular. 55 | 56 | ## Externalizing application configurations 57 | 58 | Personally, I prefer use a default hard-coded configuration in the development phase, and use environmental variables to override it in the production. 59 | 60 | Nestjs also support load custom configuration where it can read configurations from the environment variables freely. 61 | 62 | In the `AppModule`, disable `.env` file support for `ConfigModule`. 63 | 64 | ```typescript 65 | @Module({ 66 | imports:[ 67 | ConfigModule.forRoot({ ignoreEnvFile: true }), 68 | ] 69 | }) 70 | export class AppModule{} 71 | ``` 72 | 73 | Then create a *config* folder to organize all configurations in this application. 74 | 75 | In the config folder, add a new configuration for *Mongo* database. 76 | 77 | ```typescript 78 | //config/mongodb.config.ts 79 | 80 | import { registerAs } from '@nestjs/config'; 81 | 82 | export default registerAs('mongodb', () => ({ 83 | uri: process.env.MONGODB_URI || 'mongodb://localhost/blog', 84 | })); 85 | ``` 86 | 87 | Here we use `registerAs` to group the configurations related to the context *mongodb*. 88 | 89 | In the `DatabaseModule`, load the *mongodb* configuration. 90 | 91 | ```typescript 92 | import { databaseConnectionProviders } from './database-connection.providers'; 93 | 94 | @Module({ 95 | imports: [ConfigModule.forFeature(mongodbConfig)], 96 | providers: [...databaseConnectionProviders,], 97 | exports: [...databaseConnectionProviders, ], 98 | }) 99 | export class DatabaseModule { } 100 | ``` 101 | 102 | > You can also use Nestjs ConfigModule to load the configuration globally, but here we do not want to expose the mongodb config to other modules. 103 | 104 | And change the database connection providers like this. 105 | 106 | ```typescript 107 | import { ConfigType } from '@nestjs/config'; 108 | import { Connection, createConnection } from 'mongoose'; 109 | import mongodbConfig from '../config/mongodb.config'; 110 | import { DATABASE_CONNECTION } from './database.constants'; 111 | 112 | export const databaseConnectionProviders = [ 113 | { 114 | provide: DATABASE_CONNECTION, 115 | useFactory: (dbConfig: ConfigType): Connection => 116 | createConnection(dbConfig.uri, { 117 | useNewUrlParser: true, 118 | useUnifiedTopology: true, 119 | //see: https://mongoosejs.com/docs/deprecations.html#findandmodify 120 | useFindAndModify: false 121 | }), 122 | inject: [mongodbConfig.KEY], 123 | } 124 | ]; 125 | ``` 126 | 127 | In the above codes, provide a token `mongodbConfig.KEY`, you can inject a config instance as type `ConfigType` in the factory method, then you can read the configuration in a **type safe** way via `dbConfig.uri`. 128 | 129 | Similarly, create a configuration for the JWT authentication, move the existing JWT options into this configuration file. 130 | 131 | ```typescript 132 | //config/jwt.config.ts 133 | import { registerAs } from '@nestjs/config'; 134 | 135 | export default registerAs('jwt', () => ({ 136 | secretKey: process.env.JWT_SECRET_KEY || 'rzxlszyykpbgqcflzxsqcysyhljt', 137 | expiresIn: process.env.JWT_EXPIRES_IN || '3600s', 138 | })); 139 | 140 | ``` 141 | 142 | In the `AuthModule`, apply the configuration like this. 143 | 144 | ```typescript 145 | import { ConfigModule, ConfigType } from '@nestjs/config'; 146 | import jwtConfig from '../config/jwt.config'; 147 | 148 | @Module({ 149 | imports: [ 150 | ConfigModule.forFeature(jwtConfig), 151 | ... 152 | JwtModule.registerAsync({ 153 | imports: [ConfigModule.forFeature(jwtConfig)], 154 | useFactory: (config: ConfigType) => { 155 | return { 156 | secret: config.secretKey, 157 | signOptions: { expiresIn: config.expiresIn }, 158 | } as JwtModuleOptions; 159 | }, 160 | inject: [jwtConfig.KEY], 161 | }), 162 | ], 163 | .... 164 | }) 165 | export class AuthModule {} 166 | ``` 167 | 168 | And open *jwt.stretagy.ts* file, change the value of **secretOrKey** to read from `jwtConfig`. 169 | 170 | ```typescript 171 | import jwtConfig from '../../config/jwt.config'; 172 | import { ConfigType } from '@nestjs/config'; 173 | //... 174 | 175 | @Injectable() 176 | export class JwtStrategy extends PassportStrategy(Strategy) { 177 | constructor(@Inject(jwtConfig.KEY) config: ConfigType) { 178 | super({ 179 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 180 | ignoreExpiration: false, 181 | secretOrKey: config.secretKey, 182 | }); 183 | } 184 | //... 185 | } 186 | ``` 187 | 188 | In a production environment, it is easy to change these settings by simply declaring an environment variables like this. 189 | 190 | ```base 191 | export MONGODB_URI=mongodb://localhost:27019/blog 192 | ``` 193 | 194 | Or set it in the docker-compose file like this. 195 | 196 | ```typescript 197 | version: '3.8' # specify docker-compose version 198 | services: 199 | //... 200 | api: 201 | environment: 202 | - "MONGODB_URI=mongodb://mongodb:27017/blog" 203 | //... 204 | ``` 205 | 206 | We will start a new topic of deployment in the further posts. 207 | 208 | 209 | 210 | ## Testing configurations 211 | 212 | An example of `jwt-config.spec.ts`. 213 | 214 | ```typescript 215 | import { ConfigModule, ConfigType } from '@nestjs/config'; 216 | import { TestingModule, Test } from '@nestjs/testing'; 217 | import jwtConfig from './jwt.config'; 218 | 219 | describe('jwtConfig', () => { 220 | let config: ConfigType; 221 | beforeEach(async () => { 222 | const module: TestingModule = await Test.createTestingModule({ 223 | imports: [ConfigModule.forFeature(jwtConfig)], 224 | }).compile(); 225 | 226 | config = module.get>(jwtConfig.KEY); 227 | }); 228 | 229 | it('should be defined', () => { 230 | expect(jwtConfig).toBeDefined(); 231 | }); 232 | 233 | it('should contains expiresIn and secret key', async () => { 234 | expect(config.expiresIn).toBe('3600s'); 235 | expect(config.secretKey).toBe('rzxlszyykpbgqcflzxsqcysyhljt'); 236 | }); 237 | }); 238 | ``` 239 | 240 | Grab [the source codes from my github](https://github.com/hantsy/nestjs-sample), switch to branch [feat/config](https://github.com/hantsy/nestjs-sample/blob/feat/config). 241 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Building RESTful APIs with NestJS 2 | 3 | * [Getting Started](./docs/guide.md) 4 | * [Connecting to MongoDB](./docs/mongo.md) 5 | * [Protect your APIs with JWT Token](./docs/auth.md) 6 | * [Dealing with model relations](./docs/model.md) 7 | * [Externalizing the configuration](./docs/config.md) 8 | * [Handling user registration](./docs/user.md) 9 | * [Testing Nestjs applications](./docs/testing.md) -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing Nestjs applications 2 | 3 | In the previous posts, I have write a lot of testing codes to verify if our application is working as expected. 4 | 5 | Nestjs provides integration with with [Jest](https://github.com/facebook/jest) and [Supertest](https://github.com/visionmedia/supertest) out-of-the-box, and testing harness for unit testing and end-to-end (e2e) test. 6 | 7 | ## Nestjs test harness 8 | 9 | Like the Angular 's `TestBed`, Nestjs provide a similar `Test` facilities to assemble the Nestjs components for your testing codes. 10 | 11 | ```typescript 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | providers: [ 15 | ... 16 | ], 17 | }).compile(); 18 | 19 | service = module.get(UserService); 20 | }); 21 | 22 | ``` 23 | 24 | Similar to the attributes in the `@Module` decorator, `creatTestingModule` defines the components that will be used in the tests. 25 | 26 | We have demonstrated the methods to test a service in Nestjs applications, eg. in the `post.service.spec.ts`. 27 | 28 | To isolate the dependencies in a service, there are several approaches. 29 | 30 | * Create a fake service to replace the real service, assemble it in the `providers` . 31 | 32 | ```typescript 33 | providers: [ 34 | { 35 | provide: UserService, 36 | useClass: FakeUserService 37 | } 38 | ], 39 | ``` 40 | 41 | * Use a mock instance instead. 42 | 43 | ```type 44 | providers: [ 45 | provide: UserService, 46 | useValue: { 47 | send: jest.fn() 48 | } 49 | ], 50 | ``` 51 | 52 | * For simple service providers, you can escape from the Nestjs harness, and create a simple fake dependent service, and use `new` to instantize your service in the `setup` hooks. 53 | 54 | You can also import a module in `Test.createTestingModule`. 55 | 56 | ```typescript 57 | Test.createTestingModule({ 58 | imports: [] 59 | }) 60 | ``` 61 | To replace some service in the imported modules, you can `override` it. 62 | 63 | ```typescript 64 | Test.createTestingModule({ 65 | imports: [] 66 | }) 67 | .override(...) 68 | ``` 69 | 70 | ## Jest Tips and Tricks 71 | 72 | Nestjs testing is heavily dependent on Jest framework. I have spent a lot of time to research testing all components in Nestjs applications. 73 | 74 | ### Mocking external classes or functions 75 | 76 | For example the `mongoose.connect` will require a real mongo server to connect, to mock the `createConnection` of `mongoose`. 77 | 78 | Set up mocks before importing it. 79 | 80 | ```typescript 81 | jest.mock('mongoose', () => ({ 82 | createConnection: jest.fn().mockImplementation( 83 | (uri:any, options:any)=>({} as any) 84 | ), 85 | Connection: jest.fn() 86 | })) 87 | 88 | //... 89 | import { Connection, createConnection } from 'mongoose'; 90 | // 91 | ``` 92 | When a database provider is instantized, assert the `createConnection` is called. 93 | 94 | ```typescript 95 | it('connect is called', () => { 96 | //expect(conn).toBeDefined(); 97 | //expect(createConnection).toHaveBeenCalledTimes(1); // it is 2 here. why? 98 | expect(createConnection).toHaveBeenCalledWith("mongodb://localhost/blog", { 99 | useNewUrlParser: true, 100 | useUnifiedTopology: true, 101 | //see: https://mongoosejs.com/docs/deprecations.html#findandmodify 102 | useFindAndModify: false 103 | }); 104 | }) 105 | ``` 106 | 107 | ### Mock parent classes through prototype 108 | 109 | Have a look at the local auth guard tests. 110 | 111 | Mock the method `canActivate` in the parent prototype. 112 | 113 | ```typescript 114 | describe('LocalAuthGuard', () => { 115 | let guard: LocalAuthGuard; 116 | beforeEach(() => { 117 | guard = new LocalAuthGuard(); 118 | }); 119 | it('should be defined', () => { 120 | expect(guard).toBeDefined(); 121 | }); 122 | it('should return true for `canActivate`', async () => { 123 | AuthGuard('local').prototype.canActivate = jest.fn(() => 124 | Promise.resolve(true), 125 | ); 126 | AuthGuard('local').prototype.logIn = jest.fn(() => Promise.resolve()); 127 | expect(await guard.canActivate({} as ExecutionContext)).toBe(true); 128 | }); 129 | 130 | }); 131 | ``` 132 | 133 | 134 | 135 | ### Extract the functionality into functions as possible 136 | 137 | Let's have a look at the `user.model.ts`. Extract the pre `save` hook method and custom `comparePassword` method into standalone functions. 138 | 139 | ```typescript 140 | async function preSaveHook(next) { 141 | 142 | // Only run this function if password was modified 143 | if (!this.isModified('password')) return next(); 144 | 145 | // Hash the password 146 | const password = await hash(this.password, 12); 147 | this.set('password', password); 148 | 149 | next(); 150 | } 151 | 152 | UserSchema.pre('save', preSaveHook); 153 | 154 | function comparePasswordMethod(password: string): Observable { 155 | return from(compare(password, this.password)); 156 | } 157 | 158 | UserSchema.methods.comparePassword = comparePasswordMethod; 159 | ``` 160 | 161 | It is easy to test them like simple functions. 162 | 163 | ```typescript 164 | describe('preSaveHook', () => { 165 | test('should execute next middleware when password is not modified', async () => { 166 | const nextMock = jest.fn(); 167 | const contextMock = { 168 | isModified: jest.fn() 169 | }; 170 | contextMock.isModified.mockReturnValueOnce(false); 171 | await preSaveHook.call(contextMock, nextMock); 172 | expect(contextMock.isModified).toBeCalledWith('password'); 173 | expect(nextMock).toBeCalledTimes(1); 174 | }); 175 | 176 | test('should set password when password is modified', async () => { 177 | const nextMock = jest.fn(); 178 | const contextMock = { 179 | isModified: jest.fn(), 180 | set: jest.fn(), 181 | password: '123456' 182 | }; 183 | contextMock.isModified.mockReturnValueOnce(true); 184 | await preSaveHook.call(contextMock, nextMock); 185 | expect(contextMock.isModified).toBeCalledWith('password'); 186 | expect(nextMock).toBeCalledTimes(1); 187 | expect(contextMock.set).toBeCalledTimes(1); 188 | }); 189 | }); 190 | ``` 191 | 192 | 193 | 194 | ## End-to-end testing 195 | 196 | Nestjs integrates supertest to send a request to the server side. 197 | 198 | Use `beforeAll` and `afterAll` to start and stop the application, use `request` to send a http request to the server and assert the response result. 199 | 200 | ```typescript 201 | import * as request from 'supertest'; 202 | //... 203 | 204 | describe('API endpoints testing (e2e)', () => { 205 | let app: INestApplication; 206 | beforeAll(async () => { 207 | const moduleFixture: TestingModule = await Test.createTestingModule({ 208 | imports: [AppModule], 209 | }).compile(); 210 | 211 | app = moduleFixture.createNestApplication(); 212 | app.enableShutdownHooks(); 213 | app.useGlobalPipes(new ValidationPipe()); 214 | await app.init(); 215 | }); 216 | 217 | afterAll(async () => { 218 | await app.close(); 219 | }); 220 | 221 | // an example of using supertest reqruest. 222 | it('/posts (GET)', async () => { 223 | const res = await request(app.getHttpServer()).get('/posts').send(); 224 | expect(res.status).toBe(200); 225 | expect(res.body.length).toEqual(3); 226 | }); 227 | } 228 | ``` 229 | 230 | More details for the complete e2e tests, check Nestjs 's [test folder](https://github.com/hantsy/nestjs-sample/tree/master/test). 231 | -------------------------------------------------------------------------------- /docs/user.md: -------------------------------------------------------------------------------- 1 | # Handling user registration 2 | 3 | In the previous posts, the user sample data is initialized in a service which is observing an `OnMoudleInit` event. 4 | 5 | In this post we will add an endpoint to handle user registration request, including: 6 | 7 | * Add an endpoint */register* to handling user registration progress 8 | * Hashing password with bcrypt 9 | * Sending notifications via SendGrid mail service 10 | 11 | ## Registering a new user 12 | 13 | Generate a register controller. 14 | 15 | ```bash 16 | nest g controller user/register --flat 17 | ``` 18 | Fill the following content into the `RegisterController`. 19 | 20 | ```typescript 21 | // user/register.controller.ts 22 | 23 | @Controller('register') 24 | export class RegisterController { 25 | constructor(private userService: UserService) { } 26 | 27 | @Post() 28 | register( 29 | @Body() registerDto: RegisterDto, 30 | @Res() res: Response): Observable { 31 | const username = registerDto.username; 32 | 33 | return this.userService.existsByUsername(username).pipe( 34 | flatMap(exists => { 35 | if (exists) { 36 | throw new ConflictException(`username:${username} is existed`) 37 | } 38 | else { 39 | const email = registerDto.email; 40 | return this.userService.existsByEmail(email).pipe( 41 | flatMap(exists => { 42 | if (exists) { 43 | throw new ConflictException(`email:${email} is existed`) 44 | } 45 | else { 46 | return this.userService.register(registerDto).pipe( 47 | map(user => 48 | res.location('/users/' + user.id) 49 | .status(201) 50 | .send() 51 | ) 52 | ); 53 | } 54 | }) 55 | ); 56 | } 57 | }) 58 | ); 59 | } 60 | } 61 | ``` 62 | 63 | In the above codes, we will check user existence by username and email respectively, then save the user data into the MongoDB database. 64 | 65 | In the `UserService`, add the missing methods. 66 | 67 | ```typescript 68 | @Injectable() 69 | export class UserService { 70 | 71 | existsByUsername(username: string): Observable { 72 | return from(this.userModel.exists({ username })); 73 | } 74 | 75 | existsByEmail(email: string): Observable { 76 | return from(this.userModel.exists({ email })); 77 | } 78 | 79 | register(data: RegisterDto): Observable { 80 | 81 | const created = this.userModel.create({ 82 | ...data, 83 | roles: [RoleType.USER] 84 | }); 85 | 86 | return from(created); 87 | } 88 | //... 89 | } 90 | ``` 91 | 92 | Create a DTO class to represent the user registration request data. Generate the DTO skeleton firstly. 93 | 94 | ```bash 95 | nest g class user/register.dto --flat 96 | ``` 97 | And fill the following content. 98 | 99 | ```typescript 100 | import { IsEmail, IsNotEmpty, MaxLength, MinLength } from "class-validator"; 101 | 102 | export class RegisterDto { 103 | @IsNotEmpty() 104 | readonly username: string; 105 | 106 | @IsNotEmpty() 107 | @IsEmail() 108 | readonly email: string; 109 | 110 | @IsNotEmpty() 111 | @MinLength(8, { message: " The min length of password is 8 " }) 112 | @MaxLength(20, { message: " The password can't accept more than 20 characters " }) 113 | // @Matches(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])[0-9a-zA-Z]{8,20}$/, 114 | // { message: " A password at least contains one numeric digit, one supercase char and one lowercase char" } 115 | // ) 116 | readonly password: string; 117 | 118 | @IsNotEmpty() 119 | readonly firstName?: string; 120 | 121 | @IsNotEmpty() 122 | readonly lastName?: string; 123 | } 124 | ``` 125 | In the above codes, the `@IsNotEmpty()`,`@IsEmail`, `@MinLength()`, `@MaxLength()`, `@Matches()` are from `class-validator`. If you have some experience of Java EE/Jakarta EE Bean Validation or Hibernate Validators, these annotations are easy to understand. 126 | 127 | * `@IsNotEmpty()` to check if the given value is empty 128 | * `@IsEmail` to validate if the input string is an valid email format 129 | * `@MinLength()` and `@MaxLength()`are to limit the length range of the input value 130 | * `@Matches()` is flexible for custom RegExp matches. 131 | 132 | > More info about the usage of class-validator, check the details of project [typestack/class-validator](https://github.com/typestack/class-validator). 133 | 134 | In the previous posts, we have applied a global `ValidationPipe` in `bootstrap` function in the `main.ts` entry file. When registering with invalid data,it will return a 404 error. 135 | 136 | ```bash 137 | $ curl http://localhost:3000/register -d "{}" {"statusCode":400,"message":["username should not be empty","email must be an em ail","email should not be empty"," The password can't accept more than 20 charac ters "," The min length of password is 8 ","password should not be empty","first Name should not be empty","lastName should not be empty"],"error":"Bad Request"} 138 | ``` 139 | 140 | Add a test for the `RegisterController`. 141 | 142 | ```typescript 143 | describe('Register Controller', () => { 144 | let controller: RegisterController; 145 | let service: UserService; 146 | 147 | beforeEach(async () => { 148 | const module: TestingModule = await Test.createTestingModule({ 149 | controllers: [RegisterController], 150 | providers: [ 151 | { 152 | provide: UserService, 153 | useValue: { 154 | register: jest.fn(), 155 | existsByUsername: jest.fn(), 156 | existsByEmail: jest.fn() 157 | }, 158 | }, 159 | ] 160 | }).compile(); 161 | 162 | controller = module.get(RegisterController); 163 | service = module.get(UserService); 164 | }); 165 | 166 | it('should be defined', () => { 167 | expect(controller).toBeDefined(); 168 | }); 169 | 170 | describe('register', () => { 171 | it('should throw ConflictException when username is existed ', async () => { 172 | const existsByUsernameSpy = jest.spyOn(service, 'existsByUsername').mockReturnValue(of(true)); 173 | const existsByEmailSpy = jest.spyOn(service, 'existsByEmail').mockReturnValue(of(true)); 174 | const saveSpy = jest.spyOn(service, 'register').mockReturnValue(of({} as User)); 175 | 176 | const responseMock = { 177 | location: jest.fn().mockReturnThis(), 178 | json: jest.fn().mockReturnThis(), 179 | send: jest.fn().mockReturnThis() 180 | } as any; 181 | try { 182 | await controller.register({ username: 'hantsy' } as RegisterDto, responseMock).toPromise(); 183 | } catch (e) { 184 | expect(e).toBeDefined(); 185 | expect(existsByUsernameSpy).toBeCalledWith('hantsy'); 186 | expect(existsByEmailSpy).toBeCalledTimes(0); 187 | expect(saveSpy).toBeCalledTimes(0) 188 | } 189 | }); 190 | 191 | it('should throw ConflictException when email is existed ', async () => { 192 | const existsByUsernameSpy = jest.spyOn(service, 'existsByUsername').mockReturnValue(of(false)); 193 | const existsByEmailSpy = jest.spyOn(service, 'existsByEmail').mockReturnValue(of(true)); 194 | const saveSpy = jest.spyOn(service, 'register').mockReturnValue(of({} as User)); 195 | 196 | const responseMock = { 197 | location: jest.fn().mockReturnThis(), 198 | json: jest.fn().mockReturnThis(), 199 | send: jest.fn().mockReturnThis() 200 | } as any; 201 | try { 202 | await controller.register({ username: 'hantsy', email: 'hantsy@example.com' } as RegisterDto, responseMock).toPromise(); 203 | } catch (e) { 204 | expect(e).toBeDefined(); 205 | expect(existsByUsernameSpy).toBeCalledWith('hantsy'); 206 | expect(existsByEmailSpy).toBeCalledWith('hantsy@example.com'); 207 | expect(saveSpy).toBeCalledTimes(0) 208 | } 209 | }); 210 | 211 | it('should save when username and email are available ', async () => { 212 | const existsByUsernameSpy = jest.spyOn(service, 'existsByUsername').mockReturnValue(of(false)); 213 | const existsByEmailSpy = jest.spyOn(service, 'existsByEmail').mockReturnValue(of(false)); 214 | const saveSpy = jest.spyOn(service, 'register').mockReturnValue(of({ _id: '123' } as User)); 215 | 216 | const responseMock = { 217 | location: jest.fn().mockReturnThis(), 218 | status: jest.fn().mockReturnThis(), 219 | send: jest.fn().mockReturnThis() 220 | } as any; 221 | 222 | const locationSpy = jest.spyOn(responseMock, 'location'); 223 | const statusSpy = jest.spyOn(responseMock, 'status'); 224 | const sendSpy = jest.spyOn(responseMock, 'send'); 225 | 226 | await controller.register({ username: 'hantsy', email: 'hantsy@example.com' } as RegisterDto, responseMock).toPromise(); 227 | 228 | expect(existsByUsernameSpy).toBeCalledWith('hantsy'); 229 | expect(existsByEmailSpy).toBeCalledWith('hantsy@example.com'); 230 | expect(saveSpy).toBeCalledTimes(1); 231 | expect(locationSpy).toBeCalled(); 232 | expect(statusSpy).toBeCalled(); 233 | expect(sendSpy).toBeCalled(); 234 | 235 | }); 236 | }); 237 | }); 238 | ``` 239 | 240 | In the above testing codes, we go through all conditions and make sure all code blocks in the `RegisterController` are hit. 241 | 242 | Correspondingly add tests for the newly added methods in `UserService` . Here I skip the testing codes here, please check the [source code](https://github.com/hantsy/nestjs-sample/tree/feat/user) yourself. 243 | 244 | ## Hashing password 245 | 246 | In the former posts, we used plain text to store the password field in user document. In a real world application, we should choose a hash algorithm to encode the plain password for security consideration. 247 | 248 | Bcrypt is very popular for hashing password. 249 | 250 | Install `bcypt` firstly. 251 | 252 | ```bash 253 | npm install --save bcrypt 254 | ``` 255 | 256 | When saving a new user, hashing the password then save it. Add a pre save hook in the `User` model. 257 | 258 | ```typescript 259 | async function preSaveHook(next) { 260 | 261 | // Only run this function if password was modified 262 | if (!this.isModified('password')) return next(); 263 | 264 | // Hash the password 265 | const password = await hash(this.password, 12); 266 | this.set('password', password); 267 | 268 | next(); 269 | } 270 | 271 | UserSchema.pre('save', preSaveHook); 272 | ``` 273 | 274 | The `preSave` hook will be invoked before the new user data is being persisted into the MongoDB. 275 | 276 | When a user is trying to login via username and password pair, it should check if password is matched to the one in the database. 277 | 278 | Add a method to the `User` model. 279 | 280 | ```typescript 281 | function comparePasswordMethod(password: string): Observable { 282 | return from(compare(password, this.password)); 283 | } 284 | 285 | UserSchema.methods.comparePassword = comparePasswordMethod; 286 | ``` 287 | 288 | Change the `validateUser` method of the `AuthService`, check the password if matched there. 289 | 290 | ```typescript 291 | flatMap((user) => { 292 | const { _id, password, username, email, roles } = user; 293 | return user.comparePassword(pass).pipe(map(m => { 294 | if (m) { 295 | return { id: _id, username, email, roles } as UserPrincipal; 296 | }else { 297 | throw new UnauthorizedException('username or password is not matched') 298 | } 299 | })) 300 | ``` 301 | 302 | It is a little difficult to test the hooks of the `User` model, to simplify the testing work, here I extract the hooks to standalone functions, and mock the calling context in the tests. 303 | 304 | ```typescript 305 | // see: https://stackoverflow.com/questions/58701700/how-do-i-test-if-statement-inside-my-mongoose-pre-save-hook 306 | describe('preSaveHook', () => { 307 | test('should execute next middleware when password is not modified', async () => { 308 | const nextMock = jest.fn(); 309 | const contextMock = { 310 | isModified: jest.fn() 311 | }; 312 | contextMock.isModified.mockReturnValueOnce(false); 313 | await preSaveHook.call(contextMock, nextMock); 314 | expect(contextMock.isModified).toBeCalledWith('password'); 315 | expect(nextMock).toBeCalledTimes(1); 316 | }); 317 | 318 | test('should set password when password is modified', async () => { 319 | const nextMock = jest.fn(); 320 | const contextMock = { 321 | isModified: jest.fn(), 322 | set: jest.fn(), 323 | password: '123456' 324 | }; 325 | contextMock.isModified.mockReturnValueOnce(true); 326 | await preSaveHook.call(contextMock, nextMock); 327 | expect(contextMock.isModified).toBeCalledWith('password'); 328 | expect(nextMock).toBeCalledTimes(1); 329 | expect(contextMock.set).toBeCalledTimes(1); 330 | }); 331 | }); 332 | ``` 333 | 334 | Explore other tests for `comparePasswordMethod` etc in the [user.mdoel.sepc.ts](https://github.com/hantsy/nestjs-sample/blob/master/src/database/user.mdoel.spec.ts). 335 | 336 | Now run the application, have a look at the log in the console about the user initialization, as you see the password stored in the MongoDB is hashed. 337 | 338 | ```typescript 339 | (UserModule) is initialized... 340 | [ 341 | { 342 | roles: [ 'USER' ], 343 | _id: 5f477055fb9a2b3fa4cb1c21, 344 | username: 'hantsy', 345 | password: '$2b$12$/spjKM3Vdf5vRJE9u2cHaulIAWzKMbNVSyHjMp9E9PifbSEHTQrJy', 346 | email: 'hantsy@example.com', 347 | createdAt: 2020-08-27T08:35:33.800Z, 348 | updatedAt: 2020-08-27T08:35:33.800Z, 349 | __v: 0 350 | }, 351 | { 352 | roles: [ 'ADMIN' ], 353 | _id: 5f477055fb9a2b3fa4cb1c22, 354 | username: 'admin', 355 | password: '$2b$12$kFhASRJPkb/WD99J4uZrf.ZkkeKghpvf/6pgVGQArGiIgXu5aNMe.', 356 | email: 'admin@example.com', 357 | createdAt: 2020-08-27T08:35:33.801Z, 358 | updatedAt: 2020-08-27T08:35:33.801Z, 359 | __v: 0 360 | } 361 | ] 362 | ``` 363 | 364 | ## Registration Welcome Notification 365 | 366 | Generally, in a real world application, a welcome email should be sent to the new registered user when the registration is completed successfully. 367 | 368 | There are several modules can be used to send emails in NodeJS applications, for example, `nodemailer` etc. There are also some cloud service for emails, such as [SendGrid](https://sendgrid.com/). There is an existing Nestjs module to integrate SendGrid to Nestjs, check [ntegral/nestjs-sendgrid](https://github.com/ntegral/nestjs-sendgrid) project. 369 | 370 | In this sample, we will not use the existing one, and create a new home-use module for this application. 371 | 372 | Install sendgrid npm package firstly. 373 | 374 | ```bash 375 | npm i @sendgrid/mail 376 | ``` 377 | 378 | Generate a sendgrid module and a sendgrid service. 379 | 380 | ```bash 381 | nest g mo sendgrid 382 | nest g s sendgrid 383 | ``` 384 | 385 | Add the following content into the `SendgridService`. 386 | 387 | ```typescript 388 | @Injectable() 389 | export class SendgridService { 390 | 391 | constructor(@Inject(SENDGRID_MAIL) private mailService: MailService) { } 392 | 393 | send(data: MailDataRequired): Observable{ 394 | //console.log(this.mailService) 395 | return from(this.mailService.send(data, false)) 396 | } 397 | 398 | } 399 | ``` 400 | 401 | Create a provider to expose the `MailService` from `@sendgrid/mail` package. 402 | 403 | ```typescript 404 | export const sendgridProviders = [ 405 | { 406 | provide: SENDGRID_MAIL, 407 | useFactory: (config: ConfigType): MailService => 408 | { 409 | const mail = new MailService(); 410 | mail.setApiKey(config.apiKey); 411 | mail.setTimeout(5000); 412 | //mail.setTwilioEmailAuth(username, password) 413 | return mail; 414 | }, 415 | inject: [sendgridConfig.KEY], 416 | } 417 | ]; 418 | 419 | ``` 420 | 421 | Accordingly, add a config for sendgrid. 422 | 423 | ```typescript 424 | //config/sendgrid.config.ts 425 | export default registerAs('sendgrid', () => ({ 426 | apiKey: process.env.SENDGRID_API_KEY || 'SG.test', 427 | })); 428 | ``` 429 | 430 | > Signup SendGrid and generate an API Key for your applications to send emails. 431 | 432 | Declares sendgrid related config, provider and service in `SendgridModule`. 433 | 434 | ```typescript 435 | @Module({ 436 | imports: [ConfigModule.forFeature(sendgridConfig)], 437 | providers: [...sendgridProviders, SendgridService], 438 | exports: [...sendgridProviders, SendgridService] 439 | }) 440 | export class SendgridModule { } 441 | ``` 442 | 443 | Change the register function in the `UserService`. 444 | 445 | ```typescript 446 | 447 | const msg = { 448 | from: 'hantsy@gmail.com', // Use the email address or domain you verified above 449 | subject: 'Welcome to Nestjs Sample', 450 | templateId: "d-cc6080999ac04a558d632acf2d5d0b7a", 451 | personalizations: [ 452 | { 453 | to: data.email, 454 | dynamicTemplateData: { name: data.firstName + ' ' + data.lastName }, 455 | } 456 | ] 457 | 458 | }; 459 | return this.sendgridService.send(msg).pipe( 460 | catchError(err=>of(`sending email failed:${err}`)), 461 | tap(data => console.log(data)), 462 | flatMap(data => from(created)), 463 | ); 464 | ``` 465 | >The templateId is the id of the templates managed by SendGrid. SendGrid has great web UI for you to compose and manage email templates. 466 | 467 | Ideally, a user registration progress should be split into two steps. 468 | 469 | * Validate the user input data from the registration form, and persist it into the MongoDB, then send a verification number to verify the registered phone number, email, etc. In this stage, the user account will be suspended to verify. 470 | * The registered user receive the verification number or links in emails, provide it in the verification page or click the link in the email directly, and get verified. In this stage, the user account will be activated. 471 | 472 | Grab [the source codes from my github](https://github.com/hantsy/nestjs-sample), switch to branch [feat/user](https://github.com/hantsy/nestjs-sample/blob/feat/user). 473 | 474 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin'; 2 | import globals from 'globals'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import path from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | import js from '@eslint/js'; 7 | import { FlatCompat } from '@eslint/eslintrc'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default [ 18 | ...compat.extends( 19 | 'plugin:@typescript-eslint/eslint-recommended', 20 | 'plugin:@typescript-eslint/recommended', 21 | 'prettier', 22 | ), 23 | { 24 | plugins: { 25 | '@typescript-eslint': typescriptEslintEslintPlugin, 26 | }, 27 | 28 | languageOptions: { 29 | globals: { 30 | ...globals.node, 31 | ...globals.jest, 32 | }, 33 | 34 | parser: tsParser, 35 | ecmaVersion: 5, 36 | sourceType: 'module', 37 | 38 | parserOptions: { 39 | project: 'tsconfig.json', 40 | }, 41 | }, 42 | 43 | rules: { 44 | '@typescript-eslint/interface-name-prefix': 'off', 45 | '@typescript-eslint/explicit-function-return-type': 'off', 46 | '@typescript-eslint/no-explicit-any': 'off', 47 | 'no-unused-vars': 'off', 48 | '@typescript-eslint/no-unused-vars': 'off', 49 | }, 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-sample", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json", 22 | "prepare": "husky" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^11.1.0", 26 | "@nestjs/config": "^4.0.2", 27 | "@nestjs/core": "^11.1.0", 28 | "@nestjs/jwt": "^11.0.0", 29 | "@nestjs/passport": "^11.0.5", 30 | "@nestjs/platform-express": "^11.1.0", 31 | "@sendgrid/mail": "^8.1.5", 32 | "bcrypt": "^6.0.0", 33 | "class-transformer": "^0.5.1", 34 | "class-validator": "^0.14.2", 35 | "mongoose": "^8.14.2", 36 | "passport": "^0.7.0", 37 | "passport-jwt": "^4.0.1", 38 | "passport-local": "^1.0.0", 39 | "reflect-metadata": "^0.2.2", 40 | "rimraf": "^6.0.1", 41 | "rxjs": "^7.8.2" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "^19.8.1", 45 | "@commitlint/config-conventional": "^19.8.1", 46 | "@eslint/eslintrc": "^3.3.1", 47 | "@eslint/js": "^9.26.0", 48 | "@golevelup/ts-jest": "^0.7.0", 49 | "@nestjs/cli": "^11.0.7", 50 | "@nestjs/schematics": "^11.0.5", 51 | "@nestjs/testing": "^11.1.0", 52 | "@types/bcrypt": "^5.0.2", 53 | "@types/express": "^5.0.1", 54 | "@types/jest": "^29.5.14", 55 | "@types/node": "^22.15.17", 56 | "@types/passport-jwt": "^4.0.1", 57 | "@types/passport-local": "^1.0.38", 58 | "@types/supertest": "^6.0.3", 59 | "@typescript-eslint/eslint-plugin": "8.33.0", 60 | "@typescript-eslint/parser": "8.33.0", 61 | "eslint": "9.28.0", 62 | "eslint-config-prettier": "^10.1.5", 63 | "eslint-plugin-import": "^2.31.0", 64 | "globals": "^16.1.0", 65 | "husky": "^9.1.7", 66 | "jest": "^29.7.0", 67 | "jest-mock-extended": "^3.0.7", 68 | "prettier": "^3.5.3", 69 | "supertest": "^7.1.0", 70 | "ts-jest": "^29.3.2", 71 | "ts-loader": "^9.5.2", 72 | "ts-mockito": "^2.6.1", 73 | "ts-node": "^10.9.2", 74 | "tsconfig-paths": "^4.2.0", 75 | "typescript": "^5.8.3" 76 | }, 77 | "jest": { 78 | "moduleFileExtensions": [ 79 | "js", 80 | "json", 81 | "ts" 82 | ], 83 | "rootDir": "src", 84 | "testRegex": ".spec.ts$", 85 | "transform": { 86 | "^.+\\.(t|j)s$": "ts-jest" 87 | }, 88 | "coverageDirectory": "../coverage", 89 | "coveragePathIgnorePatterns": [ 90 | "/node_modules/", 91 | ".*(stub.ts)$" 92 | ], 93 | "testEnvironment": "node", 94 | "preset": "ts-jest" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppService } from './app.service'; 3 | import { AppController } from './app.controller'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | let service: AppService; 8 | 9 | beforeEach(async () => { 10 | const app: TestingModule = await Test.createTestingModule({ 11 | controllers: [AppController], 12 | providers: [ 13 | { 14 | provide: AppService, 15 | useValue: { 16 | constructor: jest.fn(), 17 | getHello: jest.fn() 18 | } 19 | } 20 | ], 21 | }).compile(); 22 | 23 | service = app.get(AppService); 24 | appController = app.get(AppController); 25 | }); 26 | it('should be defined', () => { 27 | expect(appController).toBeDefined(); 28 | }); 29 | 30 | it('getHello',async () => { 31 | jest.spyOn(service, "getHello").mockReturnValue("Hello"); 32 | expect(appController.getHello()).toEqual("Hello"); 33 | }) 34 | }); 35 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private service: AppService) { } 7 | 8 | @Get('') 9 | getHello() { 10 | return this.service.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | import { AuthModule } from './auth/auth.module'; 6 | import { DatabaseModule } from './database/database.module'; 7 | import { PostModule } from './post/post.module'; 8 | import { SendgridModule } from './sendgrid/sendgrid.module'; 9 | import { UserModule } from './user/user.module'; 10 | import { LoggerModule } from './logger/logger.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | ConfigModule.forRoot({ ignoreEnvFile: true }), 15 | DatabaseModule, 16 | PostModule, 17 | AuthModule, 18 | UserModule, 19 | SendgridModule, 20 | LoggerModule.forRoot(), 21 | ], 22 | controllers: [AppController], 23 | providers: [AppService], 24 | exports:[AppService] 25 | }) 26 | export class AppModule { } 27 | -------------------------------------------------------------------------------- /src/app.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppService } from './app.service'; 3 | import { LoggerService } from './logger/logger.service'; 4 | 5 | describe('AppService', () => { 6 | let logger: LoggerService; 7 | let service: AppService; 8 | 9 | beforeEach(async () => { 10 | const app: TestingModule = await Test.createTestingModule({ 11 | providers: [ 12 | AppService, 13 | { 14 | provide: 'LoggerServiceAppService', 15 | useValue: { 16 | constructor: jest.fn(), 17 | log: jest.fn() 18 | } 19 | } 20 | ], 21 | }) 22 | .compile(); 23 | 24 | service = app.get(AppService); 25 | logger = app.get('LoggerServiceAppService'); 26 | }); 27 | it('should be defined', () => { 28 | expect(service).toBeDefined(); 29 | }); 30 | 31 | it('getHello', async () => { 32 | jest.spyOn(logger, "log").mockImplementation((message: string) => { 33 | console.log(message); 34 | }) 35 | const result = service.getHello(); 36 | expect(result).toEqual('Hello World!'); 37 | expect(logger.log).toBeCalledWith("Hello World"); 38 | }) 39 | }); 40 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Logger } from './logger/logger.decorator'; 3 | import { LoggerService } from './logger/logger.service'; 4 | 5 | @Injectable() 6 | export class AppService { 7 | constructor(@Logger('AppService') private logger: LoggerService) {} 8 | 9 | getHello(): string { 10 | this.logger.log('Hello World'); 11 | return 'Hello World!'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/auth/auth.constants.ts: -------------------------------------------------------------------------------- 1 | export const HAS_ROLES_KEY = 'has-roles'; 2 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { lastValueFrom, of } from 'rxjs'; 3 | import { AuthController } from './auth.controller'; 4 | import { AuthService } from './auth.service'; 5 | import { Response } from 'express'; 6 | import { createMock } from '@golevelup/ts-jest'; 7 | 8 | describe('AuthController', () => { 9 | let controller: AuthController; 10 | let authService: AuthService; 11 | beforeEach(async () => { 12 | const app: TestingModule = await Test.createTestingModule({ 13 | controllers: [AuthController], 14 | providers: [ 15 | { 16 | provide: AuthService, 17 | useValue: { 18 | constructor: jest.fn(), 19 | login: jest.fn(), 20 | }, 21 | }, 22 | ], 23 | }).compile(); 24 | 25 | controller = app.get(AuthController); 26 | authService = app.get(AuthService); 27 | }); 28 | 29 | describe('login', () => { 30 | it('should return access_token', async () => { 31 | jest.spyOn(authService, 'login').mockImplementation((user: any) => { 32 | return of({ access_token: 'jwttoken' }); 33 | }); 34 | 35 | const token = await lastValueFrom( 36 | controller.login( 37 | {} as any, 38 | createMock({ 39 | header: jest.fn().mockReturnValue({ 40 | json: jest.fn().mockReturnValue({ 41 | send: jest.fn().mockReturnValue({ 42 | header: { authorization: 'Bearer test' }, 43 | }), 44 | }), 45 | }), 46 | }), 47 | ), 48 | ); 49 | expect(token).toBeTruthy(); 50 | expect(authService.login).toBeCalled(); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Req, Res, UseGuards } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { Observable } from 'rxjs'; 4 | import { map } from 'rxjs/operators'; 5 | import { AuthService } from './auth.service'; 6 | import { LocalAuthGuard } from './guard/local-auth.guard'; 7 | import { AuthenticatedRequest } from './interface/authenticated-request.interface'; 8 | 9 | @Controller('auth') 10 | export class AuthController { 11 | constructor(private authService: AuthService) { } 12 | 13 | @UseGuards(LocalAuthGuard) 14 | @Post('login') 15 | login(@Req() req: AuthenticatedRequest, @Res() res: Response): Observable { 16 | return this.authService.login(req.user) 17 | .pipe( 18 | map(token => { 19 | return res 20 | .header('Authorization', 'Bearer ' + token.access_token) 21 | .json(token) 22 | .send() 23 | }) 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { UserModule } from '../user/user.module'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { LocalStrategy } from './strategy/local.strategy'; 6 | import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; 7 | import { JwtStrategy } from './strategy/jwt.strategy'; 8 | import { ConfigModule, ConfigType } from '@nestjs/config'; 9 | import jwtConfig from '../config/jwt.config'; 10 | import { AuthController } from './auth.controller'; 11 | 12 | @Module({ 13 | imports: [ 14 | ConfigModule.forFeature(jwtConfig), 15 | UserModule, 16 | PassportModule.register({ defaultStrategy: 'jwt' }), 17 | JwtModule.registerAsync({ 18 | imports: [ConfigModule.forFeature(jwtConfig)], 19 | useFactory: (config: ConfigType) => { 20 | return { 21 | secret: config.secretKey, 22 | signOptions: { expiresIn: config.expiresIn }, 23 | } as JwtModuleOptions; 24 | }, 25 | inject: [jwtConfig.KEY], 26 | }), 27 | ], 28 | providers: [AuthService, LocalStrategy, JwtStrategy], 29 | exports: [AuthService], 30 | controllers: [AuthController], 31 | }) 32 | export class AuthModule {} 33 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtService } from '@nestjs/jwt'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { of } from 'rxjs'; 4 | import { User } from '../database/user.model'; 5 | import { UserService } from '../user/user.service'; 6 | import { AuthService } from './auth.service'; 7 | import { RoleType } from '../shared/enum/role-type.enum'; 8 | 9 | describe('AuthService', () => { 10 | let service: AuthService; 11 | let userService: UserService; 12 | let jwtService: JwtService; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | providers: [ 17 | AuthService, 18 | { 19 | provide: UserService, 20 | useValue: { 21 | constructor: jest.fn(), 22 | findByUsername: jest.fn(), 23 | }, 24 | }, 25 | { 26 | provide: JwtService, 27 | useValue: { 28 | constructor: jest.fn(), 29 | signAsync: jest.fn(), 30 | }, 31 | }, 32 | ], 33 | }).compile(); 34 | 35 | service = module.get(AuthService); 36 | userService = module.get(UserService); 37 | jwtService = module.get(JwtService); 38 | }); 39 | 40 | it('should be defined', () => { 41 | expect(service).toBeDefined(); 42 | }); 43 | 44 | describe('validateUser', () => { 45 | it('if user is found', async () => { 46 | jest 47 | .spyOn(userService, 'findByUsername') 48 | .mockImplementation((username: string) => { 49 | return of({ 50 | username, 51 | password: 'password', 52 | email: 'hantsy@example.com', 53 | roles: [RoleType.USER], 54 | comparePassword: (password: string) => of(true) 55 | } as User); 56 | }); 57 | 58 | service.validateUser('test', 'password').subscribe({ 59 | next: (data) => { 60 | expect(data.username).toBe('test'); 61 | // expect(data.password).toBeUndefined(); 62 | expect(data.email).toBe('hantsy@example.com'); 63 | expect(data.roles).toEqual([RoleType.USER]); 64 | 65 | //verify 66 | expect(userService.findByUsername).toBeCalledTimes(1); 67 | expect(userService.findByUsername).toBeCalledWith('test'); 68 | }, 69 | }); 70 | }); 71 | 72 | it('if user is found but pass is mismatched', async () => { 73 | jest 74 | .spyOn(userService, 'findByUsername') 75 | .mockImplementation((username: string) => { 76 | return of({ 77 | username, 78 | password: 'password', 79 | email: 'hantsy@example.com', 80 | roles: [RoleType.USER], 81 | comparePassword: (password: string) => of(false) 82 | } as User); 83 | }); 84 | 85 | service 86 | .validateUser('test', 'password001') 87 | .subscribe({ 88 | next: (data) => console.log(data), 89 | error: error => expect(error).toBeDefined() 90 | }); 91 | }); 92 | 93 | it('if user is not found', async () => { 94 | jest 95 | .spyOn(userService, 'findByUsername') 96 | .mockImplementation((username: string) => { 97 | return of(null as User); 98 | }); 99 | 100 | 101 | service 102 | .validateUser('test', 'password001') 103 | .subscribe({ 104 | next: (data) => console.log(data), 105 | error: error => expect(error).toBeDefined() 106 | }); 107 | }); 108 | }); 109 | 110 | describe('login', () => { 111 | it('should return signed jwt token', async () => { 112 | jest.spyOn(jwtService, 'signAsync').mockResolvedValue('test'); 113 | 114 | service 115 | .login({ 116 | username: 'test', 117 | id: '_id', 118 | email: 'hantsy@example.com', 119 | roles: [RoleType.USER], 120 | }) 121 | .subscribe({ 122 | next: (data) => { 123 | expect(data.access_token).toBe('test'); 124 | expect(jwtService.signAsync).toBeCalledTimes(1); 125 | expect(jwtService.signAsync).toBeCalledWith({ 126 | upn: 'test', 127 | sub: '_id', 128 | email: 'hantsy@example.com', 129 | roles: [RoleType.USER], 130 | }); 131 | }, 132 | }); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { EMPTY, from, Observable, of } from 'rxjs'; 4 | import { mergeMap, map, throwIfEmpty } from 'rxjs/operators'; 5 | import { UserService } from '../user/user.service'; 6 | import { AccessToken } from './interface/access-token.interface'; 7 | import { JwtPayload } from './interface/jwt-payload.interface'; 8 | import { UserPrincipal } from './interface/user-principal.interface'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | constructor( 13 | private userService: UserService, 14 | private jwtService: JwtService, 15 | ) { } 16 | 17 | validateUser(username: string, pass: string): Observable { 18 | return this.userService.findByUsername(username).pipe( 19 | //if user is not found, convert it into an EMPTY. 20 | mergeMap((p) => (p ? of(p) : EMPTY)), 21 | 22 | // Using a general message in the authentication progress is more reasonable. 23 | // Concise info could be considered for security. 24 | // Detailed info will be helpful for crackers. 25 | // throwIfEmpty(() => new NotFoundException(`username:${username} was not found`)), 26 | throwIfEmpty(() => new UnauthorizedException(`username or password is not matched`)), 27 | 28 | mergeMap((user) => { 29 | const { _id, password, username, email, roles } = user; 30 | return user.comparePassword(pass).pipe(map(m => { 31 | if (m) { 32 | return { id: _id, username, email, roles } as UserPrincipal; 33 | }else { 34 | // The same reason above. 35 | //throw new UnauthorizedException('password was not matched.') 36 | throw new UnauthorizedException('username or password is not matched') 37 | } 38 | })) 39 | }) 40 | ); 41 | } 42 | 43 | // If `LocalStrateg#validateUser` return a `Observable`, the `request.user` is 44 | // bound to a `Observable`, not a `UserPrincipal`. 45 | // 46 | // I would like use the current `Promise` for this case, thus it will get 47 | // a `UserPrincipal` here directly. 48 | // 49 | login(user: UserPrincipal): Observable { 50 | //console.log(user); 51 | const payload: JwtPayload = { 52 | upn: user.username, //upn is defined in Microprofile JWT spec, a human readable principal name. 53 | sub: user.id, 54 | email: user.email, 55 | roles: user.roles, 56 | }; 57 | return from(this.jwtService.signAsync(payload)).pipe( 58 | map((access_token) => { 59 | return { access_token }; 60 | }), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/auth/guard/has-roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { RoleType } from '../../shared/enum/role-type.enum'; 3 | import { HAS_ROLES_KEY } from '../auth.constants'; 4 | 5 | export const HasRoles = (...args: RoleType[]) => SetMetadata(HAS_ROLES_KEY, args); 6 | -------------------------------------------------------------------------------- /src/auth/guard/jwt-auth.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { JwtAuthGuard } from './jwt-auth.guard'; 5 | 6 | describe('LocalAuthGuard', () => { 7 | let guard: JwtAuthGuard; 8 | beforeEach(() => { 9 | guard = new JwtAuthGuard(); 10 | }); 11 | 12 | it('should be defined', () => { 13 | expect(guard).toBeDefined(); 14 | }); 15 | 16 | it('should return true for `canActivate`', async () => { 17 | AuthGuard('jwt').prototype.canActivate = jest.fn(() => 18 | Promise.resolve(true), 19 | ); 20 | AuthGuard('jwt').prototype.logIn = jest.fn(() => Promise.resolve()); 21 | expect( 22 | await guard.canActivate(createMock()), 23 | ).toBeTruthy(); 24 | }); 25 | 26 | it('handleRequest: error', async () => { 27 | const error = { name: 'test', message: 'error' } as Error; 28 | 29 | try { 30 | guard.handleRequest(error, {}, {}); 31 | } catch (e) { 32 | //console.log(e); 33 | expect(e).toEqual(error); 34 | } 35 | }); 36 | 37 | it('handleRequest', async () => { 38 | expect( 39 | await guard.handleRequest(undefined, { username: 'hantsy' }, undefined), 40 | ).toEqual({ username: 'hantsy' }); 41 | }); 42 | 43 | it('handleRequest: Unauthorized', async () => { 44 | try { 45 | guard.handleRequest(undefined, undefined, undefined); 46 | } catch (e) { 47 | // console.log(e); 48 | expect(e).toBeDefined(); 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/auth/guard/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | ExecutionContext, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { AuthGuard } from '@nestjs/passport'; 7 | import { Observable } from 'rxjs'; 8 | 9 | @Injectable() 10 | export class JwtAuthGuard extends AuthGuard('jwt') { 11 | canActivate( 12 | context: ExecutionContext, 13 | ): boolean | Promise | Observable { 14 | // Add your custom authentication logic here 15 | // for example, call super.logIn(request) to establish a session. 16 | return super.canActivate(context); 17 | } 18 | 19 | handleRequest(err, user, info) { 20 | // You can throw an exception based on either "info" or "err" arguments 21 | if (err || !user) { 22 | throw err || new UnauthorizedException(); 23 | } 24 | return user; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/auth/guard/local-auth.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { LocalAuthGuard } from './local-auth.guard'; 4 | 5 | describe('LocalAuthGuard', () => { 6 | let guard: LocalAuthGuard; 7 | beforeEach(() => { 8 | guard = new LocalAuthGuard(); 9 | }); 10 | it('should be defined', () => { 11 | expect(guard).toBeDefined(); 12 | }); 13 | it('should return true for `canActivate`', async () => { 14 | AuthGuard('local').prototype.canActivate = jest.fn(() => 15 | Promise.resolve(true), 16 | ); 17 | AuthGuard('local').prototype.logIn = jest.fn(() => Promise.resolve()); 18 | expect(await guard.canActivate({} as ExecutionContext)).toBe(true); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /src/auth/guard/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/auth/guard/roles.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | import { HttpArgumentsHost } from '@nestjs/common/interfaces'; 4 | import { Reflector } from '@nestjs/core'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | import { mock as jestMock, mockClear } from 'jest-mock-extended'; 7 | import { instance, mock, reset, verify, when } from 'ts-mockito'; 8 | import { RoleType } from '../../shared/enum/role-type.enum'; 9 | import { HAS_ROLES_KEY } from '../auth.constants'; 10 | import { AuthenticatedRequest } from '../interface/authenticated-request.interface'; 11 | import { RolesGuard } from './roles.guard'; 12 | 13 | describe('RolesGuard', () => { 14 | let guard: RolesGuard; 15 | let reflector: Reflector; 16 | beforeEach(async () => { 17 | const module: TestingModule = await Test.createTestingModule({ 18 | providers: [ 19 | RolesGuard, 20 | { 21 | provide: Reflector, 22 | useValue: { 23 | constructor: jest.fn(), 24 | get: jest.fn(), 25 | }, 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | guard = module.get(RolesGuard); 31 | reflector = module.get(Reflector); 32 | }); 33 | 34 | afterEach(async () => { 35 | jest.clearAllMocks(); 36 | }); 37 | 38 | it('should be defined', () => { 39 | expect(guard).toBeDefined(); 40 | }); 41 | 42 | it('should skip(return true) if the `HasRoles` decorator is not set', async () => { 43 | jest.spyOn(reflector, 'get').mockImplementation((a: any, b: any) => []); 44 | const context = createMock(); 45 | const result = await guard.canActivate(context); 46 | 47 | expect(result).toBeTruthy(); 48 | expect(reflector.get).toBeCalled(); 49 | }); 50 | 51 | it('should return true if the `HasRoles` decorator is set', async () => { 52 | jest 53 | .spyOn(reflector, 'get') 54 | .mockImplementation((a: any, b: any) => [RoleType.USER]); 55 | const context = createMock({ 56 | getHandler: jest.fn(), 57 | switchToHttp: jest.fn().mockReturnValue({ 58 | getRequest: jest.fn().mockReturnValue({ 59 | user: { roles: [RoleType.USER] }, 60 | } as AuthenticatedRequest), 61 | }), 62 | }); 63 | 64 | const result = await guard.canActivate(context); 65 | expect(result).toBeTruthy(); 66 | expect(reflector.get).toBeCalled(); 67 | }); 68 | 69 | it('should return false if the `HasRoles` decorator is set but role is not allowed', async () => { 70 | jest.spyOn(reflector, 'get').mockReturnValue([RoleType.ADMIN]); 71 | const request = { 72 | user: { roles: [RoleType.USER] }, 73 | } as AuthenticatedRequest; 74 | const context = createMock(); 75 | const httpArgsHost = createMock({ 76 | getRequest: () => request, 77 | }); 78 | context.switchToHttp.mockImplementation(() => httpArgsHost); 79 | 80 | const result = await guard.canActivate(context); 81 | expect(result).toBeFalsy(); 82 | expect(reflector.get).toBeCalled(); 83 | }); 84 | }); 85 | 86 | describe('RolesGuard(ts-mockito)', () => { 87 | let guard: RolesGuard; 88 | const reflecter = mock(Reflector); 89 | beforeEach(() => { 90 | guard = new RolesGuard(instance(reflecter)); 91 | }); 92 | 93 | afterEach(() => { 94 | reset(); 95 | }); 96 | 97 | it('should skip(return true) if the `HasRoles` decorator is not set', async () => { 98 | const context = mock(); 99 | when(context.getHandler()).thenReturn({} as any); 100 | 101 | const contextInstacne = instance(context); 102 | when( 103 | reflecter.get(HAS_ROLES_KEY, contextInstacne.getHandler()), 104 | ).thenReturn([] as RoleType[]); 105 | const result = await guard.canActivate(contextInstacne); 106 | 107 | expect(result).toBeTruthy(); 108 | verify( 109 | reflecter.get(HAS_ROLES_KEY, contextInstacne.getHandler()), 110 | ).once(); 111 | }); 112 | 113 | it('should return true if the `HasRoles` decorator is set', async () => { 114 | const context = mock(); 115 | 116 | when(context.getHandler()).thenReturn({} as any); 117 | 118 | const arguHost = mock(); 119 | when(arguHost.getRequest()).thenReturn({ 120 | user: { roles: [RoleType.USER] }, 121 | } as any); 122 | 123 | when(context.switchToHttp()).thenReturn(instance(arguHost)); 124 | const contextInstacne = instance(context); 125 | 126 | when( 127 | reflecter.get(HAS_ROLES_KEY, contextInstacne.getHandler()), 128 | ).thenReturn([RoleType.USER] as RoleType[]); 129 | 130 | const result = await guard.canActivate(contextInstacne); 131 | console.log(result); 132 | expect(result).toBeTruthy(); 133 | verify( 134 | reflecter.get(HAS_ROLES_KEY, contextInstacne.getHandler()), 135 | ).once(); 136 | }); 137 | 138 | it('should return false if the `HasRoles` decorator is set but role is not allowed', async () => { 139 | const context = mock(); 140 | 141 | when(context.getHandler()).thenReturn({} as any); 142 | 143 | // logged in as USER 144 | const arguHost = mock(); 145 | when(arguHost.getRequest()).thenReturn({ 146 | user: { roles: [RoleType.USER] }, 147 | } as any); 148 | 149 | when(context.switchToHttp()).thenReturn(instance(arguHost)); 150 | const contextInstacne = instance(context); 151 | 152 | // but requires ADMIN 153 | when( 154 | reflecter.get(HAS_ROLES_KEY, contextInstacne.getHandler()), 155 | ).thenReturn([RoleType.ADMIN] as RoleType[]); 156 | 157 | const result = await guard.canActivate(contextInstacne); 158 | console.log(result); 159 | expect(result).toBeFalsy(); 160 | verify( 161 | reflecter.get(HAS_ROLES_KEY, contextInstacne.getHandler()), 162 | ).once(); 163 | }); 164 | }); 165 | 166 | describe('RoelsGuard(jest-mock-extended)', () => { 167 | let guard: RolesGuard; 168 | const reflecter = jestMock(); 169 | 170 | beforeEach(() => { 171 | guard = new RolesGuard(reflecter); 172 | }); 173 | 174 | afterEach(() => { 175 | mockClear(reflecter); 176 | }); 177 | 178 | it('should be defined', () => { 179 | expect(guard).toBeDefined(); 180 | }); 181 | 182 | it('should skip(return true) if the `HasRoles` decorator is not set', async () => { 183 | const context = jestMock(); 184 | context.getHandler.mockReturnValue({} as any); 185 | reflecter.get 186 | .mockReturnValue([]) 187 | .calledWith(HAS_ROLES_KEY, context.getHandler()); 188 | 189 | const result = await guard.canActivate(context); 190 | 191 | expect(result).toBeTruthy(); 192 | expect(reflecter.get).toBeCalledTimes(1); 193 | }); 194 | 195 | it('should return true if the `HasRoles` decorator is set', async () => { 196 | const context = jestMock(); 197 | context.getHandler.mockReturnValue({} as any); 198 | 199 | const arguHost = jestMock(); 200 | arguHost.getRequest.mockReturnValue({ 201 | user: { roles: [RoleType.USER] }, 202 | } as any); 203 | 204 | context.switchToHttp.mockReturnValue(arguHost); 205 | 206 | reflecter.get 207 | .mockReturnValue([RoleType.USER]) 208 | .calledWith(HAS_ROLES_KEY, context.getHandler()); 209 | 210 | const result = await guard.canActivate(context); 211 | 212 | expect(result).toBeTruthy(); 213 | expect(reflecter.get).toBeCalledTimes(1); 214 | }); 215 | 216 | it('should return false if the `HasRoles` decorator is set but role is not allowed', async () => { 217 | // logged in as USER 218 | const context = jestMock(); 219 | context.getHandler.mockReturnValue({} as any); 220 | 221 | const arguHost = jestMock(); 222 | arguHost.getRequest.mockReturnValue({ 223 | user: { roles: [RoleType.USER] }, 224 | } as any); 225 | 226 | context.switchToHttp.mockReturnValue(arguHost); 227 | 228 | //but requires ADMIN 229 | reflecter.get 230 | .mockReturnValue([RoleType.ADMIN]) 231 | .calledWith(HAS_ROLES_KEY, context.getHandler()); 232 | 233 | const result = await guard.canActivate(context); 234 | 235 | expect(result).toBeFalsy(); 236 | expect(reflecter.get).toBeCalledTimes(1); 237 | }); 238 | }); 239 | -------------------------------------------------------------------------------- /src/auth/guard/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { Observable } from 'rxjs'; 4 | import { RoleType } from '../../shared/enum/role-type.enum'; 5 | import { HAS_ROLES_KEY } from '../auth.constants'; 6 | import { AuthenticatedRequest } from '../interface/authenticated-request.interface'; 7 | 8 | @Injectable() 9 | export class RolesGuard implements CanActivate { 10 | constructor(private readonly reflector: Reflector) {} 11 | canActivate( 12 | context: ExecutionContext, 13 | ): boolean | Promise | Observable { 14 | const roles = this.reflector.get( 15 | HAS_ROLES_KEY, 16 | context.getHandler(), 17 | ); 18 | if (!roles || roles.length == 0) { 19 | return true; 20 | } 21 | 22 | const { 23 | user, 24 | } = context.switchToHttp().getRequest() as AuthenticatedRequest; 25 | return user.roles && user.roles.some((r) => roles.includes(r)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/interface/access-token.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AccessToken{ 2 | readonly access_token:string 3 | } 4 | -------------------------------------------------------------------------------- /src/auth/interface/authenticated-request.interface.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { UserPrincipal } from './user-principal.interface'; 3 | 4 | export interface AuthenticatedRequest extends Request { 5 | readonly user: UserPrincipal; 6 | } 7 | -------------------------------------------------------------------------------- /src/auth/interface/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | import { RoleType } from "../../shared/enum/role-type.enum"; 2 | 3 | export interface JwtPayload { 4 | readonly upn: string; 5 | readonly sub: string; 6 | readonly email: string; 7 | readonly roles: RoleType[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/interface/user-principal.interface.ts: -------------------------------------------------------------------------------- 1 | import { RoleType } from '../../shared/enum/role-type.enum'; 2 | 3 | export interface UserPrincipal { 4 | readonly username: string; 5 | readonly id: string; 6 | readonly email: string; 7 | readonly roles: RoleType[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/strategy/jwt.strategy.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigType } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { mock } from 'jest-mock-extended'; 4 | import jwtConfig from '../../config/jwt.config'; 5 | import { RoleType } from '../../shared/enum/role-type.enum'; 6 | import { JwtStrategy } from './jwt.strategy'; 7 | 8 | describe('JwtStrategy', () => { 9 | let strategy: JwtStrategy; 10 | let config: ConfigType; 11 | beforeEach(async () => { 12 | const app: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | JwtStrategy, 15 | { 16 | provide: jwtConfig.KEY, 17 | useValue: { 18 | secretKey: "test", 19 | expiresIn:'100s' 20 | }, 21 | }, 22 | ], 23 | }) 24 | .compile(); 25 | 26 | strategy = app.get(JwtStrategy); 27 | config = app.get>(jwtConfig.KEY); 28 | }); 29 | 30 | describe('validate', () => { 31 | it('should return user principal if user and password is provided ', async () => { 32 | expect(config.secretKey).toBe('test') 33 | expect(config.expiresIn).toBe('100s') 34 | const user = await strategy.validate({ 35 | upn: "test", 36 | sub: 'testid', 37 | email: "test@example.com", 38 | roles: [RoleType.USER] 39 | }); 40 | expect(user.username).toEqual('test'); 41 | expect(user.id).toEqual('testid'); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('JwtStrategy(call supper)', () => { 47 | let local; 48 | let parentMock; 49 | 50 | beforeEach(() => { 51 | local = Object.getPrototypeOf(JwtStrategy); 52 | parentMock = jest.fn(); 53 | Object.setPrototypeOf(JwtStrategy, parentMock); 54 | }); 55 | 56 | afterEach(() => { 57 | Object.setPrototypeOf(JwtStrategy, local); 58 | }); 59 | 60 | it('call super', () => { 61 | const config = mock>(); 62 | config.secretKey="test"; 63 | new JwtStrategy(config); 64 | expect(parentMock.mock.calls.length).toBe(1); 65 | 66 | expect(parentMock.mock.calls[0][0].jwtFromRequest).toBeDefined(); 67 | expect(parentMock.mock.calls[0][0].ignoreExpiration).toBeFalsy(); 68 | expect(parentMock.mock.calls[0][0].secretOrKey).toEqual("test"); 69 | 70 | }) 71 | }); 72 | -------------------------------------------------------------------------------- /src/auth/strategy/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import jwtConfig from '../../config/jwt.config'; 5 | import { ConfigType } from '@nestjs/config'; 6 | import { JwtPayload } from '../interface/jwt-payload.interface'; 7 | import { UserPrincipal } from '../interface/user-principal.interface'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor(@Inject(jwtConfig.KEY) config: ConfigType) { 12 | super({ 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | ignoreExpiration: false, 15 | secretOrKey: config.secretKey, 16 | }); 17 | } 18 | 19 | //payload is the decoded jwt clmais. 20 | validate(payload: JwtPayload): UserPrincipal { 21 | //console.log('jwt payload:' + JSON.stringify(payload)); 22 | return { 23 | username: payload.upn, 24 | email: payload.email, 25 | id: payload.sub, 26 | roles: payload.roles, 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/strategy/local.strategy.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { mock } from 'jest-mock-extended'; 3 | import { EMPTY, of } from 'rxjs'; 4 | import { RoleType } from '../../shared/enum/role-type.enum'; 5 | import { AuthService } from '../auth.service'; 6 | import { LocalStrategy } from './local.strategy'; 7 | 8 | describe('LocalStrategy', () => { 9 | let strategy: LocalStrategy; 10 | let authService: AuthService; 11 | beforeEach(async () => { 12 | const app: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | LocalStrategy, 15 | { 16 | provide: AuthService, 17 | useValue: { 18 | constructor: jest.fn(), 19 | login: jest.fn(), 20 | validateUser: jest.fn() 21 | }, 22 | }, 23 | ], 24 | }).compile(); 25 | 26 | strategy = app.get(LocalStrategy); 27 | authService = app.get(AuthService); 28 | }); 29 | 30 | describe('validate', () => { 31 | it('should return user principal if user and password is provided ', async () => { 32 | jest 33 | .spyOn(authService, 'validateUser') 34 | .mockImplementation((user: any, pass: any) => { 35 | return of({ 36 | username: 'test', 37 | id: '_id', 38 | email: 'hantsy@example.com', 39 | roles: [RoleType.USER], 40 | }); 41 | }); 42 | const user = await strategy.validate('test', 'pass'); 43 | expect(user.username).toEqual('test'); 44 | expect(authService.validateUser).toBeCalledWith('test', 'pass'); 45 | }); 46 | 47 | it('should throw UnauthorizedException if user is not valid ', async () => { 48 | jest 49 | .spyOn(authService, 'validateUser') 50 | .mockImplementation((user: any, pass: any) => { 51 | return EMPTY; 52 | }); 53 | 54 | try { 55 | const user = await strategy.validate('test', 'pass'); 56 | } catch (e) { 57 | //console.log(e) 58 | expect(e).toBeDefined() 59 | } 60 | expect(authService.validateUser).toBeCalledWith('test', 'pass'); 61 | }); 62 | }); 63 | }); 64 | 65 | describe('LocalStrategy(call supper)', () => { 66 | let local; 67 | let parentMock; 68 | 69 | beforeEach(() => { 70 | local = Object.getPrototypeOf(LocalStrategy); 71 | parentMock = jest.fn(); 72 | Object.setPrototypeOf(LocalStrategy, parentMock); 73 | }); 74 | 75 | afterEach(() => { 76 | Object.setPrototypeOf(LocalStrategy, local); 77 | }); 78 | 79 | it('call super', () => { 80 | new LocalStrategy(mock()); 81 | expect(parentMock.mock.calls.length).toBe(1); 82 | expect(parentMock).toBeCalledWith({ 83 | usernameField: 'username', 84 | passwordField: 'password', 85 | }); 86 | }) 87 | }); 88 | -------------------------------------------------------------------------------- /src/auth/strategy/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { lastValueFrom } from 'rxjs'; 5 | import { AuthService } from '../auth.service'; 6 | import { UserPrincipal } from '../interface/user-principal.interface'; 7 | 8 | @Injectable() 9 | export class LocalStrategy extends PassportStrategy(Strategy) { 10 | constructor(private authService: AuthService) { 11 | super({ 12 | usernameField: 'username', 13 | passwordField: 'password', 14 | }); 15 | } 16 | 17 | // When using Observable as return type, the exeption in the pipeline is ignored. 18 | // In our case, the `UnauthorizedException` is **NOT** caught and handled as expected. 19 | // The flow is NOT prevented by the exception and continue to send a `Observable` to 20 | // the next step aka calling `this.authService.login` in `AppController#login` method. 21 | // Then the jwt token is generated in any case(eg. wrong username or wrong password), 22 | // the authenticatoin worflow does not work as expected. 23 | // 24 | // The solution is customizing `PassportSerializer`. 25 | // Example: https://github.com/jmcdo29/zeldaPlay/blob/master/apps/api/src/app/auth/session.serializer.ts 26 | // 27 | // validate(username: string, password: string): Observable { 28 | // return this.authService 29 | // .validateUser(username, password) 30 | // .pipe(throwIfEmpty(() => new UnauthorizedException())); 31 | // } 32 | 33 | async validate(username: string, password: string): Promise { 34 | const user: UserPrincipal = await lastValueFrom( 35 | this.authService.validateUser(username, password), 36 | ); 37 | 38 | if (!user) { 39 | throw new UnauthorizedException(); 40 | } 41 | 42 | return user; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/config/jwt.config.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigType } from '@nestjs/config'; 2 | import { TestingModule, Test } from '@nestjs/testing'; 3 | import jwtConfig from './jwt.config'; 4 | 5 | describe('jwtConfig', () => { 6 | let config: ConfigType; 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | imports: [ConfigModule.forFeature(jwtConfig)], 10 | }).compile(); 11 | 12 | config = module.get>(jwtConfig.KEY); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(jwtConfig).toBeDefined(); 17 | }); 18 | 19 | it('should contains expiresIn and secret key', async () => { 20 | expect(config.expiresIn).toBe('3600s'); 21 | expect(config.secretKey).toBe('rzxlszyykpbgqcflzxsqcysyhljt'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/config/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('jwt', () => ({ 4 | secretKey: process.env.JWT_SECRET_KEY || 'rzxlszyykpbgqcflzxsqcysyhljt', 5 | expiresIn: process.env.JWT_EXPIRES_IN || '3600s', 6 | })); 7 | -------------------------------------------------------------------------------- /src/config/mongodb.config.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigType } from '@nestjs/config'; 2 | import { TestingModule, Test } from '@nestjs/testing'; 3 | import mongodbConfig from './mongodb.config'; 4 | 5 | describe('mongodbConfig', () => { 6 | let config: ConfigType; 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | imports: [ConfigModule.forFeature(mongodbConfig)], 10 | }).compile(); 11 | 12 | config = module.get>(mongodbConfig.KEY); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(mongodbConfig).toBeDefined(); 17 | }); 18 | 19 | it('should contains uri key', async () => { 20 | expect(config.uri).toBe('mongodb://localhost/blog'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/config/mongodb.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('mongodb', () => ({ 4 | uri: process.env.MONGODB_URI || 'mongodb://localhost/blog', 5 | })); 6 | -------------------------------------------------------------------------------- /src/config/sendgrid.config.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigType } from '@nestjs/config'; 2 | import { TestingModule, Test } from '@nestjs/testing'; 3 | import sendgridConfig from './sendgrid.config'; 4 | 5 | describe('sendgridConfig', () => { 6 | let config: ConfigType; 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | imports: [ConfigModule.forFeature(sendgridConfig)], 10 | }).compile(); 11 | 12 | config = module.get>(sendgridConfig.KEY); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(sendgridConfig).toBeDefined(); 17 | }); 18 | 19 | it('should contains expiresIn and secret key', async () => { 20 | expect(config.apiKey).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/config/sendgrid.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('sendgrid', () => ({ 4 | apiKey: process.env.SENDGRID_API_KEY || 'SG.test', 5 | })); 6 | -------------------------------------------------------------------------------- /src/database/comment.model.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Document, Model, Schema, SchemaTypes } from 'mongoose'; 2 | import { Post } from './post.model'; 3 | import { User } from './user.model'; 4 | 5 | interface Comment extends Document { 6 | readonly content: string; 7 | readonly post?: Partial; 8 | readonly createdBy?: Partial; 9 | readonly updatedBy?: Partial; 10 | } 11 | 12 | type CommentModel = Model; 13 | 14 | const CommentSchema = new Schema( 15 | { 16 | content: SchemaTypes.String, 17 | post: { type: SchemaTypes.ObjectId, ref: 'Post', required: false }, 18 | createdBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false }, 19 | updatedBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false }, 20 | }, 21 | { timestamps: true }, 22 | ); 23 | 24 | const createCommentModel: (conn: Connection) => CommentModel = ( 25 | connection: Connection, 26 | ) => connection.model('Comment', CommentSchema, 'comments'); 27 | 28 | export { Comment, CommentModel, createCommentModel }; 29 | -------------------------------------------------------------------------------- /src/database/database-connection.providers.spec.ts: -------------------------------------------------------------------------------- 1 | jest.mock('mongoose', () => ({ 2 | createConnection: jest.fn().mockImplementation( 3 | (uri:any, options:any)=>({} as any) 4 | ), 5 | Connection: jest.fn() 6 | })) 7 | 8 | import { ConfigModule } from '@nestjs/config'; 9 | import { Test, TestingModule } from '@nestjs/testing'; 10 | import { Connection, createConnection } from 'mongoose'; 11 | import mongodbConfig from '../config/mongodb.config'; 12 | import { databaseConnectionProviders } from './database-connection.providers'; 13 | import { DATABASE_CONNECTION } from './database.constants'; 14 | 15 | describe('DatabaseConnectionProviders', () => { 16 | let conn: any; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | imports: [ConfigModule.forFeature(mongodbConfig)], 21 | providers: [...databaseConnectionProviders], 22 | }).compile(); 23 | 24 | conn = module.get(DATABASE_CONNECTION); 25 | }); 26 | 27 | 28 | 29 | it('DATABASE_CONNECTION should be defined', () => { 30 | expect(conn).toBeDefined(); 31 | }); 32 | 33 | it('connect is called', () => { 34 | //expect(conn).toBeDefined(); 35 | //expect(createConnection).toHaveBeenCalledTimes(1); // it is 2 here. why? 36 | expect(createConnection).toHaveBeenCalledWith("mongodb://localhost/blog", { 37 | // useNewUrlParser: true, 38 | // useUnifiedTopology: true, 39 | //see: https://mongoosejs.com/docs/deprecations.html#findandmodify 40 | // useFindAndModify: false 41 | }); 42 | }) 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /src/database/database-connection.providers.ts: -------------------------------------------------------------------------------- 1 | import { ConfigType } from '@nestjs/config'; 2 | import { Connection, createConnection } from 'mongoose'; 3 | import mongodbConfig from '../config/mongodb.config'; 4 | import { DATABASE_CONNECTION } from './database.constants'; 5 | 6 | export const databaseConnectionProviders = [ 7 | { 8 | provide: DATABASE_CONNECTION, 9 | useFactory: (dbConfig: ConfigType): Connection => { 10 | const conn = createConnection(dbConfig.uri, { 11 | //useNewUrlParser: true, 12 | //useUnifiedTopology: true, 13 | //see: https://mongoosejs.com/docs/deprecations.html#findandmodify 14 | //useFindAndModify: false, 15 | }); 16 | 17 | // conn.on('disconnect', () => { 18 | // console.log('Disconnecting to MongoDB'); 19 | // }); 20 | 21 | return conn; 22 | }, 23 | inject: [mongodbConfig.KEY], 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /src/database/database-models.providers.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Connection, Model } from 'mongoose'; 3 | import { Comment, CommentModel } from './comment.model'; 4 | import { 5 | COMMENT_MODEL, 6 | DATABASE_CONNECTION, 7 | POST_MODEL, 8 | USER_MODEL, 9 | } from './database.constants'; 10 | import { databaseModelsProviders } from './database-models.providers'; 11 | import { Post, PostModel } from './post.model'; 12 | import { User, UserModel } from './user.model'; 13 | 14 | describe('DatabaseModelsProviders', () => { 15 | let conn: any; 16 | let userModel: any; 17 | let postModel: any; 18 | let commentModel: any; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | providers: [ 23 | ...databaseModelsProviders, 24 | 25 | { 26 | provide: DATABASE_CONNECTION, 27 | useValue: { 28 | model: jest 29 | .fn() 30 | .mockReturnValue({} as Model), 31 | }, 32 | }, 33 | ], 34 | }).compile(); 35 | 36 | conn = module.get(DATABASE_CONNECTION); 37 | userModel = module.get(USER_MODEL); 38 | postModel = module.get(POST_MODEL); 39 | commentModel = module.get(COMMENT_MODEL); 40 | }); 41 | 42 | it('DATABASE_CONNECTION should be defined', () => { 43 | expect(conn).toBeDefined(); 44 | }); 45 | 46 | it('USER_MODEL should be defined', () => { 47 | expect(userModel).toBeDefined(); 48 | }); 49 | 50 | it('POST_MODEL should be defined', () => { 51 | expect(postModel).toBeDefined(); 52 | }); 53 | 54 | it('COMMENT_MODEL should be defined', () => { 55 | expect(commentModel).toBeDefined(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/database/database-models.providers.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'mongoose'; 2 | import { Comment, createCommentModel } from './comment.model'; 3 | import { 4 | COMMENT_MODEL, 5 | DATABASE_CONNECTION, 6 | POST_MODEL, 7 | USER_MODEL, 8 | } from './database.constants'; 9 | import { Post, createPostModel } from './post.model'; 10 | import { createUserModel } from './user.model'; 11 | 12 | export const databaseModelsProviders = [ 13 | { 14 | provide: POST_MODEL, 15 | useFactory: (connection: Connection) => createPostModel(connection), 16 | inject: [DATABASE_CONNECTION], 17 | }, 18 | { 19 | provide: COMMENT_MODEL, 20 | useFactory: (connection: Connection) => createCommentModel(connection), 21 | inject: [DATABASE_CONNECTION], 22 | }, 23 | { 24 | provide: USER_MODEL, 25 | useFactory: (connection: Connection) => createUserModel(connection), 26 | inject: [DATABASE_CONNECTION], 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/database/database.constants.ts: -------------------------------------------------------------------------------- 1 | export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; 2 | export const POST_MODEL = 'POST_MODEL'; 3 | export const USER_MODEL = 'USER_MODEL'; 4 | export const COMMENT_MODEL = 'COMMENT_MODEL'; 5 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import mongodbConfig from '../config/mongodb.config'; 4 | import { databaseConnectionProviders } from './database-connection.providers'; 5 | import { databaseModelsProviders } from './database-models.providers'; 6 | 7 | @Module({ 8 | imports: [ConfigModule.forFeature(mongodbConfig)], 9 | providers: [...databaseConnectionProviders, ...databaseModelsProviders], 10 | exports: [...databaseConnectionProviders, ...databaseModelsProviders], 11 | }) 12 | export class DatabaseModule { } 13 | -------------------------------------------------------------------------------- /src/database/post.model.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Document, Model, Schema, SchemaTypes } from 'mongoose'; 2 | import { User } from './user.model'; 3 | 4 | interface Post extends Document { 5 | readonly title: string; 6 | readonly content: string; 7 | readonly createdBy?: Partial; 8 | readonly updatedBy?: Partial; 9 | } 10 | 11 | type PostModel = Model; 12 | 13 | const PostSchema = new Schema( 14 | { 15 | title: SchemaTypes.String, 16 | content: SchemaTypes.String, 17 | createdBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false }, 18 | updatedBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false }, 19 | }, 20 | { timestamps: true }, 21 | ); 22 | 23 | const createPostModel: (conn: Connection) => PostModel = (conn: Connection) => 24 | conn.model('Post', PostSchema, 'posts'); 25 | 26 | export { Post, PostModel, createPostModel }; 27 | -------------------------------------------------------------------------------- /src/database/user.mdoel.spec.ts: -------------------------------------------------------------------------------- 1 | const getMock = jest.fn().mockImplementationOnce((cb) => cb); 2 | const virtualMock = jest.fn().mockImplementationOnce((name: string) => ({ 3 | get: getMock, 4 | })); 5 | 6 | jest.mock('mongoose', () => ({ 7 | Schema: jest.fn().mockImplementation((def: any, options: any) => ({ 8 | constructor: jest.fn(), 9 | virtual: virtualMock, 10 | pre: jest.fn(), 11 | set: jest.fn(), 12 | methods: { comparePassword: jest.fn() }, 13 | comparePassword: jest.fn(), 14 | })), 15 | SchemaTypes: jest.fn().mockImplementation(() => ({ 16 | String: jest.fn(), 17 | })), 18 | })); 19 | 20 | import { anyFunction } from 'jest-mock-extended'; 21 | import { 22 | UserSchema, 23 | preSaveHook, 24 | nameGetHook, 25 | comparePasswordMethod, 26 | } from './user.model'; 27 | import { hash } from 'bcrypt'; 28 | import { lastValueFrom } from 'rxjs'; 29 | 30 | describe('UserSchema', () => { 31 | it('should called Schame.virtual ', () => { 32 | expect(UserSchema).toBeDefined(); 33 | 34 | expect(getMock).toBeCalled(); 35 | expect(getMock).toBeCalledWith(anyFunction()); 36 | expect(virtualMock).toBeCalled(); 37 | expect(virtualMock).toHaveBeenNthCalledWith(1, 'name'); 38 | expect(virtualMock).toHaveBeenNthCalledWith(2, 'posts', { 39 | foreignField: 'createdBy', 40 | localField: '_id', 41 | ref: 'Post', 42 | }); 43 | expect(virtualMock).toBeCalledTimes(2); 44 | }); 45 | }); 46 | 47 | // see: https://stackoverflow.com/questions/58701700/how-do-i-test-if-statement-inside-my-mongoose-pre-save-hook 48 | describe('preSaveHook', () => { 49 | test('should execute next middleware when password is not modified', async () => { 50 | const nextMock = jest.fn(); 51 | const contextMock = { 52 | isModified: jest.fn(), 53 | }; 54 | contextMock.isModified.mockReturnValueOnce(false); 55 | await preSaveHook.call(contextMock, nextMock); 56 | expect(contextMock.isModified).toBeCalledWith('password'); 57 | expect(nextMock).toBeCalledTimes(1); 58 | }); 59 | 60 | test('should set password when password is modified', async () => { 61 | const nextMock = jest.fn(); 62 | const contextMock = { 63 | isModified: jest.fn(), 64 | set: jest.fn(), 65 | password: '123456', 66 | }; 67 | contextMock.isModified.mockReturnValueOnce(true); 68 | await preSaveHook.call(contextMock, nextMock); 69 | expect(contextMock.isModified).toBeCalledWith('password'); 70 | expect(nextMock).toBeCalledTimes(1); 71 | expect(contextMock.set).toBeCalledTimes(1); 72 | }); 73 | }); 74 | 75 | describe('nameGetHook', () => { 76 | test('should compute name with firstName and lastName', async () => { 77 | const contextMock = { 78 | firstName: 'Hantsy', 79 | lastName: 'Bai', 80 | }; 81 | const name = await nameGetHook.call(contextMock); 82 | expect(name).toBe('Hantsy Bai'); 83 | }); 84 | }); 85 | 86 | describe('comparePasswordMethod', () => { 87 | test('should be true if password is matched', async () => { 88 | const hashed = await hash('123456', 10); 89 | const contextMock = { 90 | password: hashed, 91 | }; 92 | 93 | const result = await lastValueFrom( 94 | comparePasswordMethod.call(contextMock, '123456'), 95 | ); 96 | expect(result).toBeTruthy(); 97 | }); 98 | 99 | test('should be false if password is not matched', async () => { 100 | const hashed = await hash('123456', 10); 101 | const contextMock = { 102 | password: hashed, 103 | }; 104 | 105 | // input password is wrong 106 | const result = await lastValueFrom( 107 | comparePasswordMethod.call(contextMock, '000000'), 108 | ); 109 | expect(result).toBeFalsy(); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/database/user.model.ts: -------------------------------------------------------------------------------- 1 | import { compare, hash } from 'bcrypt'; 2 | import { Connection, Document, Model, Schema, SchemaTypes } from 'mongoose'; 3 | import { from, Observable } from 'rxjs'; 4 | import { RoleType } from '../shared/enum/role-type.enum'; 5 | interface User extends Document { 6 | comparePassword(password: string): Observable; 7 | readonly username: string; 8 | readonly email: string; 9 | readonly password: string; 10 | readonly firstName?: string; 11 | readonly lastName?: string; 12 | readonly roles?: RoleType[]; 13 | } 14 | 15 | type UserModel = Model; 16 | 17 | const UserSchema = new Schema( 18 | { 19 | username: SchemaTypes.String, 20 | password: SchemaTypes.String, 21 | email: SchemaTypes.String, 22 | firstName: { type: SchemaTypes.String, required: false }, 23 | lastName: { type: SchemaTypes.String, required: false }, 24 | roles: [ 25 | { type: SchemaTypes.String, enum: ['ADMIN', 'USER'], required: false }, 26 | ], 27 | // use timestamps option to generate it automaticially. 28 | // createdAt: { type: SchemaTypes.Date, required: false }, 29 | // updatedAt: { type: SchemaTypes.Date, required: false }, 30 | }, 31 | { 32 | timestamps: true, 33 | toJSON: { 34 | virtuals: true, 35 | }, 36 | }, 37 | ); 38 | 39 | // see: https://wanago.io/2020/05/25/api-nestjs-authenticating-users-bcrypt-passport-jwt-cookies/ 40 | // and https://stackoverflow.com/questions/48023018/nodejs-bcrypt-async-mongoose-login 41 | async function preSaveHook(next) { 42 | // Only run this function if password was modified 43 | if (!this.isModified('password')) return next(); 44 | 45 | // Hash the password 46 | const password = await hash(this.password, 12); 47 | this.set('password', password); 48 | 49 | next(); 50 | } 51 | 52 | UserSchema.pre('save', preSaveHook); 53 | 54 | function comparePasswordMethod(password: string): Observable { 55 | return from(compare(password, this.password)); 56 | } 57 | 58 | UserSchema.methods.comparePassword = comparePasswordMethod; 59 | 60 | function nameGetHook() { 61 | return `${this.firstName} ${this.lastName}`; 62 | } 63 | 64 | UserSchema.virtual('name').get(nameGetHook); 65 | 66 | UserSchema.virtual('posts', { 67 | ref: 'Post', 68 | localField: '_id', 69 | foreignField: 'createdBy', 70 | }); 71 | 72 | const createUserModel: (conn: Connection) => UserModel = (conn: Connection) => 73 | conn.model('User', UserSchema, 'users'); 74 | 75 | export { 76 | User, 77 | UserModel, 78 | createUserModel, 79 | UserSchema, 80 | preSaveHook, 81 | nameGetHook, 82 | comparePasswordMethod, 83 | }; 84 | -------------------------------------------------------------------------------- /src/logger/logger.decorator.ts: -------------------------------------------------------------------------------- 1 | // src/logger/logger.decorator.ts 2 | 3 | import { Inject } from '@nestjs/common'; 4 | 5 | export const prefixesForLoggers: string[] = new Array(); 6 | 7 | export function Logger(prefix = '') { 8 | if (!prefixesForLoggers.includes(prefix)) { 9 | prefixesForLoggers.push(prefix); 10 | } 11 | return Inject(`LoggerService${prefix}`); 12 | } 13 | -------------------------------------------------------------------------------- /src/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | // src/logger/logger.module.ts 2 | 3 | import { DynamicModule } from '@nestjs/common'; 4 | import { createLoggerProviders } from './logger.providers'; 5 | import { LoggerService } from './logger.service'; 6 | 7 | export class LoggerModule { 8 | static forRoot(): DynamicModule { 9 | const prefixedLoggerProviders = createLoggerProviders(); 10 | return { 11 | module: LoggerModule, 12 | providers: [LoggerService, ...prefixedLoggerProviders], 13 | exports: [LoggerService, ...prefixedLoggerProviders], 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/logger/logger.providers.ts: -------------------------------------------------------------------------------- 1 | // src/logger/logger.provider.ts 2 | 3 | import { Provider } from '@nestjs/common'; 4 | import { prefixesForLoggers } from './logger.decorator'; 5 | import { LoggerService } from './logger.service'; 6 | 7 | function loggerFactory(logger: LoggerService, prefix: string) { 8 | if (prefix) { 9 | logger.setPrefix(prefix); 10 | } 11 | return logger; 12 | } 13 | 14 | function createLoggerProvider(prefix: string): Provider { 15 | return { 16 | provide: `LoggerService${prefix}`, 17 | useFactory: logger => loggerFactory(logger, prefix), 18 | inject: [LoggerService], 19 | }; 20 | } 21 | 22 | export function createLoggerProviders(): Array> { 23 | return prefixesForLoggers.map(prefix => createLoggerProvider(prefix)); 24 | } 25 | -------------------------------------------------------------------------------- /src/logger/logger.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { LoggerService } from './logger.service'; 3 | 4 | describe('LoggerService', () => { 5 | let service: LoggerService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [LoggerService], 10 | }).compile(); 11 | 12 | service = await module.resolve(LoggerService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | 19 | it('log', () => { 20 | const consoleSpy = jest.spyOn(global.console, 'log'); 21 | service.log("hello"); 22 | expect(consoleSpy).toBeCalledWith('hello'); 23 | }); 24 | 25 | it('log with prefix', () => { 26 | const consoleSpy = jest.spyOn(global.console, 'log'); 27 | service.setPrefix("H") 28 | service.log("hello"); 29 | expect(consoleSpy).toBeCalledWith('[H] hello'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Scope } from '@nestjs/common'; 2 | 3 | @Injectable({ 4 | scope: Scope.TRANSIENT, 5 | }) 6 | export class LoggerService { 7 | private prefix?: string; 8 | 9 | log(message: string) { 10 | let formattedMessage = message; 11 | 12 | if (this.prefix) { 13 | formattedMessage = `[${this.prefix}] ${message}`; 14 | } 15 | 16 | console.log(formattedMessage); 17 | } 18 | 19 | setPrefix(prefix: string) { 20 | this.prefix = prefix; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | 8 | // enable shutdown hooks explicitly. 9 | app.enableShutdownHooks(); 10 | 11 | app.useGlobalPipes(new ValidationPipe()); 12 | app.enableCors(); 13 | //app.useLogger(); 14 | await app.listen(3000); 15 | } 16 | bootstrap(); 17 | -------------------------------------------------------------------------------- /src/post/create-comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | export class CreateCommentDto { 3 | 4 | @IsNotEmpty() 5 | readonly content: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/post/create-post.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | export class CreatePostDto { 3 | 4 | @IsNotEmpty() 5 | readonly title: string; 6 | 7 | @IsNotEmpty() 8 | readonly content: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/post/post-data-initializer.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Inject, 3 | Injectable, 4 | OnModuleInit 5 | } from '@nestjs/common'; 6 | import { Model } from 'mongoose'; 7 | import { Comment } from '../database/comment.model'; 8 | import { COMMENT_MODEL, POST_MODEL } from '../database/database.constants'; 9 | import { Post } from '../database/post.model'; 10 | import { CreatePostDto } from './create-post.dto'; 11 | 12 | @Injectable() 13 | export class PostDataInitializerService 14 | implements OnModuleInit { 15 | private data: CreatePostDto[] = [ 16 | { 17 | title: 'Generate a NestJS project', 18 | content: 'content', 19 | }, 20 | { 21 | title: 'Create CRUD RESTful APIs', 22 | content: 'content', 23 | }, 24 | { 25 | title: 'Connect to MongoDB', 26 | content: 'content', 27 | }, 28 | ]; 29 | 30 | constructor( 31 | @Inject(POST_MODEL) private postModel: Model, 32 | @Inject(COMMENT_MODEL) private commentModel: Model, 33 | ) { } 34 | 35 | async onModuleInit(): Promise { 36 | console.log('(PostModule) is initialized...'); 37 | await this.postModel.deleteMany({}); 38 | await this.commentModel.deleteMany({}); 39 | await this.postModel.insertMany(this.data).then((r) => console.log(r)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/post/post.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { lastValueFrom, Observable, of } from 'rxjs'; 3 | import { anyNumber, anyString, instance, mock, verify, when } from 'ts-mockito'; 4 | import { Post } from '../database/post.model'; 5 | import { CreatePostDto } from './create-post.dto'; 6 | import { PostController } from './post.controller'; 7 | import { PostService } from './post.service'; 8 | import { PostServiceStub } from './post.service.stub'; 9 | import { UpdatePostDto } from './update-post.dto'; 10 | import { createMock } from '@golevelup/ts-jest'; 11 | import { Response } from 'express'; 12 | 13 | describe('Post Controller', () => { 14 | describe('Replace PostService in provider(useClass: PostServiceStub)', () => { 15 | let controller: PostController; 16 | 17 | beforeEach(async () => { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | providers: [ 20 | { 21 | provide: PostService, 22 | useClass: PostServiceStub, 23 | }, 24 | ], 25 | controllers: [PostController], 26 | }).compile(); 27 | 28 | controller = await module.resolve(PostController); 29 | }); 30 | 31 | it('should be defined', () => { 32 | expect(controller).toBeDefined(); 33 | }); 34 | 35 | it('GET on /posts should return all posts', async () => { 36 | const posts = await lastValueFrom(controller.getAllPosts()); 37 | expect(posts.length).toBe(3); 38 | }); 39 | 40 | it('GET on /posts/:id should return one post ', (done) => { 41 | controller.getPostById('1').subscribe((data) => { 42 | expect(data._id).toEqual('1'); 43 | done(); 44 | }); 45 | }); 46 | 47 | it('POST on /posts should save post', async () => { 48 | const post: CreatePostDto = { 49 | title: 'test title', 50 | content: 'test content', 51 | }; 52 | const saved = await lastValueFrom( 53 | controller.createPost( 54 | post, 55 | createMock({ 56 | location: jest.fn().mockReturnValue({ 57 | status: jest.fn().mockReturnValue({ 58 | send: jest.fn().mockReturnValue({ 59 | headers: { location: '/posts/post_id' }, 60 | status: 201, 61 | }), 62 | }), 63 | }), 64 | }), 65 | ), 66 | ); 67 | // console.log(saved); 68 | expect(saved.status).toBe(201); 69 | }); 70 | 71 | it('PUT on /posts/:id should update the existing post', (done) => { 72 | const post: UpdatePostDto = { 73 | title: 'test title', 74 | content: 'test content', 75 | }; 76 | controller 77 | .updatePost( 78 | '1', 79 | post, 80 | createMock({ 81 | status: jest.fn().mockReturnValue({ 82 | send: jest.fn().mockReturnValue({ 83 | status: 204, 84 | }), 85 | }), 86 | }), 87 | ) 88 | .subscribe((data) => { 89 | expect(data.status).toBe(204); 90 | done(); 91 | }); 92 | }); 93 | 94 | it('DELETE on /posts/:id should delete post', (done) => { 95 | controller 96 | .deletePostById( 97 | '1', 98 | createMock({ 99 | status: jest.fn().mockReturnValue({ 100 | send: jest.fn().mockReturnValue({ 101 | status: 204, 102 | }), 103 | }), 104 | }), 105 | ) 106 | .subscribe((data) => { 107 | expect(data).toBeTruthy(); 108 | done(); 109 | }); 110 | }); 111 | 112 | it('POST on /posts/:id/comments', async () => { 113 | const result = await lastValueFrom( 114 | controller.createCommentForPost( 115 | 'testpost', 116 | { content: 'testcomment' }, 117 | createMock({ 118 | location: jest.fn().mockReturnValue({ 119 | status: jest.fn().mockReturnValue({ 120 | send: jest.fn().mockReturnValue({ 121 | headers: { location: '/posts/post_id/comments/comment_id' }, 122 | status: 201, 123 | }), 124 | }), 125 | }), 126 | }), 127 | ), 128 | ); 129 | 130 | expect(result.status).toBe(201); 131 | }); 132 | 133 | it('GET on /posts/:id/comments', async () => { 134 | const result = await lastValueFrom( 135 | controller.getAllCommentsOfPost('testpost'), 136 | ); 137 | 138 | expect(result.length).toBe(1); 139 | }); 140 | }); 141 | 142 | describe('Replace PostService in provider(useValue: fake object)', () => { 143 | let controller: PostController; 144 | 145 | beforeEach(async () => { 146 | const module: TestingModule = await Test.createTestingModule({ 147 | providers: [ 148 | { 149 | provide: PostService, 150 | useValue: { 151 | findAll: (_keyword?: string, _skip?: number, _limit?: number) => 152 | of([ 153 | { 154 | _id: 'testid', 155 | title: 'test title', 156 | content: 'test content', 157 | }, 158 | ]), 159 | }, 160 | }, 161 | ], 162 | controllers: [PostController], 163 | }).compile(); 164 | 165 | controller = await module.resolve(PostController); 166 | }); 167 | 168 | it('should get all posts(useValue: fake object)', async () => { 169 | const result = await lastValueFrom(controller.getAllPosts()); 170 | expect(result[0]._id).toEqual('testid'); 171 | }); 172 | }); 173 | 174 | describe('Replace PostService in provider(useValue: jest mocked object)', () => { 175 | let controller: PostController; 176 | let postService: PostService; 177 | 178 | beforeEach(async () => { 179 | const module: TestingModule = await Test.createTestingModule({ 180 | providers: [ 181 | { 182 | provide: PostService, 183 | useValue: { 184 | constructor: jest.fn(), 185 | findAll: jest 186 | .fn() 187 | .mockImplementation( 188 | (_keyword?: string, _skip?: number, _limit?: number) => 189 | of([ 190 | { 191 | _id: 'testid', 192 | title: 'test title', 193 | content: 'test content', 194 | }, 195 | ]), 196 | ), 197 | }, 198 | }, 199 | ], 200 | controllers: [PostController], 201 | }).compile(); 202 | 203 | controller = await module.resolve(PostController); 204 | postService = module.get(PostService); 205 | }); 206 | 207 | it('should get all posts(useValue: jest mocking)', async () => { 208 | const result = await lastValueFrom(controller.getAllPosts('test', 10, 0)); 209 | expect(result[0]._id).toEqual('testid'); 210 | expect(postService.findAll).toBeCalled(); 211 | expect(postService.findAll).lastCalledWith('test', 0, 10); 212 | }); 213 | }); 214 | 215 | describe('Mocking PostService using ts-mockito', () => { 216 | let controller: PostController; 217 | const mockedPostService: PostService = mock(PostService); 218 | 219 | beforeEach(async () => { 220 | controller = new PostController(instance(mockedPostService)); 221 | }); 222 | 223 | it('should get all posts(ts-mockito)', async () => { 224 | when( 225 | mockedPostService.findAll(anyString(), anyNumber(), anyNumber()), 226 | ).thenReturn( 227 | of([ 228 | { _id: 'testid', title: 'test title', content: 'content' }, 229 | ]) as Observable, 230 | ); 231 | const result = await lastValueFrom(controller.getAllPosts('', 10, 0)); 232 | expect(result.length).toEqual(1); 233 | expect(result[0].title).toBe('test title'); 234 | verify( 235 | mockedPostService.findAll(anyString(), anyNumber(), anyNumber()), 236 | ).once(); 237 | }); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /src/post/post.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | DefaultValuePipe, 5 | Delete, 6 | Get, 7 | Param, 8 | ParseIntPipe, 9 | Post, 10 | Put, 11 | Query, 12 | Res, 13 | Scope, 14 | UseGuards 15 | } from '@nestjs/common'; 16 | import { Response } from 'express'; 17 | import { Observable } from 'rxjs'; 18 | import { map } from 'rxjs/operators'; 19 | import { RoleType } from '../shared/enum/role-type.enum'; 20 | import { HasRoles } from '../auth/guard/has-roles.decorator'; 21 | import { JwtAuthGuard } from '../auth/guard/jwt-auth.guard'; 22 | import { RolesGuard } from '../auth/guard/roles.guard'; 23 | import { ParseObjectIdPipe } from '../shared/pipe/parse-object-id.pipe'; 24 | import { Comment } from '../database/comment.model'; 25 | import { Post as BlogPost } from '../database/post.model'; 26 | import { CreateCommentDto } from './create-comment.dto'; 27 | import { CreatePostDto } from './create-post.dto'; 28 | import { PostService } from './post.service'; 29 | import { UpdatePostDto } from './update-post.dto'; 30 | 31 | @Controller({ path: 'posts', scope: Scope.REQUEST }) 32 | export class PostController { 33 | constructor(private readonly postService: PostService) { } 34 | 35 | @Get('') 36 | getAllPosts( 37 | @Query('q') keyword?: string, 38 | @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit?: number, 39 | @Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip?: number, 40 | ): Observable { 41 | return this.postService.findAll(keyword, skip, limit); 42 | } 43 | 44 | @Get(':id') 45 | getPostById(@Param('id', ParseObjectIdPipe) id: string): Observable { 46 | return this.postService.findById(id); 47 | } 48 | 49 | @Post('') 50 | @UseGuards(JwtAuthGuard, RolesGuard) 51 | @HasRoles(RoleType.USER, RoleType.ADMIN) 52 | createPost( 53 | @Body() post: CreatePostDto, 54 | @Res() res: Response, 55 | ): Observable { 56 | return this.postService.save(post).pipe( 57 | map((post) => { 58 | return res 59 | .location('/posts/' + post._id) 60 | .status(201) 61 | .send(); 62 | }), 63 | ); 64 | } 65 | 66 | @Put(':id') 67 | @UseGuards(JwtAuthGuard, RolesGuard) 68 | @HasRoles(RoleType.USER, RoleType.ADMIN) 69 | updatePost( 70 | @Param('id', ParseObjectIdPipe) id: string, 71 | @Body() post: UpdatePostDto, 72 | @Res() res: Response, 73 | ): Observable { 74 | return this.postService.update(id, post).pipe( 75 | map((post) => { 76 | return res.status(204).send(); 77 | }), 78 | ); 79 | } 80 | 81 | @Delete(':id') 82 | @UseGuards(JwtAuthGuard, RolesGuard) 83 | @HasRoles(RoleType.ADMIN) 84 | deletePostById( 85 | @Param('id', ParseObjectIdPipe) id: string, 86 | @Res() res: Response, 87 | ): Observable { 88 | return this.postService.deleteById(id).pipe( 89 | map((post) => { 90 | return res.status(204).send(); 91 | }), 92 | ); 93 | } 94 | 95 | @Post(':id/comments') 96 | @UseGuards(JwtAuthGuard, RolesGuard) 97 | @HasRoles(RoleType.USER) 98 | createCommentForPost( 99 | @Param('id', ParseObjectIdPipe) id: string, 100 | @Body() data: CreateCommentDto, 101 | @Res() res: Response, 102 | ): Observable { 103 | return this.postService.createCommentFor(id, data).pipe( 104 | map((comment) => { 105 | return res 106 | .location('/posts/' + id + '/comments/' + comment._id) 107 | .status(201) 108 | .send(); 109 | }), 110 | ); 111 | } 112 | 113 | @Get(':id/comments') 114 | getAllCommentsOfPost(@Param('id', ParseObjectIdPipe) id: string): Observable { 115 | return this.postService.commentsOf(id); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/post/post.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DatabaseModule } from '../database/database.module'; 3 | import { PostDataInitializerService } from './post-data-initializer.service'; 4 | import { PostController } from './post.controller'; 5 | import { PostService } from './post.service'; 6 | 7 | @Module({ 8 | imports: [DatabaseModule], 9 | controllers: [PostController], 10 | providers: [PostService, PostDataInitializerService], 11 | }) 12 | export class PostModule{} 13 | // implements NestModule { 14 | // configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void { 15 | // consumer 16 | // .apply(AuthenticationMiddleware) 17 | // .forRoutes( 18 | // { method: RequestMethod.POST, path: '/posts' }, 19 | // { method: RequestMethod.PUT, path: '/posts/:id' }, 20 | // { method: RequestMethod.DELETE, path: '/posts/:id' }, 21 | // ); 22 | // } 23 | // } 24 | -------------------------------------------------------------------------------- /src/post/post.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { REQUEST } from '@nestjs/core'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { Model } from 'mongoose'; 4 | import { lastValueFrom } from 'rxjs'; 5 | 6 | import { Comment } from '../database/comment.model'; 7 | import { COMMENT_MODEL, POST_MODEL } from '../database/database.constants'; 8 | import { Post } from '../database/post.model'; 9 | import { PostService } from './post.service'; 10 | 11 | describe('PostService', () => { 12 | let service: PostService; 13 | let model: Model; 14 | let commentModel: Model; 15 | 16 | beforeEach(async () => { 17 | const module: TestingModule = await Test.createTestingModule({ 18 | providers: [ 19 | PostService, 20 | { 21 | provide: POST_MODEL, 22 | useValue: { 23 | new: jest.fn(), 24 | constructor: jest.fn(), 25 | find: jest.fn(), 26 | findOne: jest.fn(), 27 | update: jest.fn(), 28 | create: jest.fn(), 29 | remove: jest.fn(), 30 | exec: jest.fn(), 31 | deleteMany: jest.fn(), 32 | deleteOne: jest.fn(), 33 | updateOne: jest.fn(), 34 | findOneAndUpdate: jest.fn(), 35 | findOneAndDelete: jest.fn(), 36 | }, 37 | }, 38 | { 39 | provide: COMMENT_MODEL, 40 | useValue: { 41 | new: jest.fn(), 42 | constructor: jest.fn(), 43 | find: jest.fn(), 44 | findOne: jest.fn(), 45 | updateOne: jest.fn(), 46 | deleteOne: jest.fn(), 47 | update: jest.fn(), 48 | create: jest.fn(), 49 | remove: jest.fn(), 50 | exec: jest.fn(), 51 | }, 52 | }, 53 | { 54 | provide: REQUEST, 55 | useValue: { 56 | user: { 57 | id: 'dummyId', 58 | }, 59 | }, 60 | }, 61 | ], 62 | }).compile(); 63 | 64 | service = await module.resolve(PostService); 65 | model = module.get>(POST_MODEL); 66 | commentModel = module.get>(COMMENT_MODEL); 67 | }); 68 | 69 | it('should be defined', () => { 70 | expect(service).toBeDefined(); 71 | }); 72 | 73 | it('findAll should return all posts', async () => { 74 | const posts = [ 75 | { 76 | _id: '5ee49c3115a4e75254bb732e', 77 | title: 'Generate a NestJS project', 78 | content: 'content', 79 | }, 80 | { 81 | _id: '5ee49c3115a4e75254bb732f', 82 | title: 'Create CRUD RESTful APIs', 83 | content: 'content', 84 | }, 85 | { 86 | _id: '5ee49c3115a4e75254bb7330', 87 | title: 'Connect to MongoDB', 88 | content: 'content', 89 | }, 90 | ]; 91 | jest.spyOn(model, 'find').mockReturnValue({ 92 | skip: jest.fn().mockReturnValue({ 93 | limit: jest.fn().mockReturnValue({ 94 | exec: jest.fn().mockResolvedValueOnce(posts) as any, 95 | }), 96 | }), 97 | } as any); 98 | 99 | const data = await lastValueFrom(service.findAll()); 100 | expect(data.length).toBe(3); 101 | expect(model.find).toHaveBeenCalled(); 102 | 103 | jest.spyOn(model, 'find').mockImplementation(() => { 104 | return { 105 | skip: jest.fn().mockReturnValue({ 106 | limit: jest.fn().mockReturnValue({ 107 | exec: jest.fn().mockResolvedValueOnce([posts[0]]), 108 | }), 109 | }), 110 | } as any; 111 | }); 112 | 113 | const result = await lastValueFrom(service.findAll('Generate', 0, 10)); 114 | expect(result.length).toBe(1); 115 | expect(model.find).lastCalledWith({ 116 | title: { $regex: '.*' + 'Generate' + '.*' }, 117 | }); 118 | }); 119 | 120 | describe('findByid', () => { 121 | it('if exists return one post', (done) => { 122 | const found = { 123 | _id: '5ee49c3115a4e75254bb732e', 124 | title: 'Generate a NestJS project', 125 | content: 'content', 126 | }; 127 | 128 | jest.spyOn(model, 'findOne').mockReturnValue({ 129 | exec: jest.fn().mockResolvedValueOnce(found) as any, 130 | } as any); 131 | 132 | service.findById('1').subscribe({ 133 | next: (data) => { 134 | expect(data._id).toBe('5ee49c3115a4e75254bb732e'); 135 | expect(data.title).toEqual('Generate a NestJS project'); 136 | }, 137 | error: (error) => console.log(error), 138 | complete: done(), 139 | }); 140 | }); 141 | 142 | it('if not found throw an NotFoundException', (done) => { 143 | jest.spyOn(model, 'findOne').mockReturnValue({ 144 | exec: jest.fn().mockResolvedValueOnce(null) as any, 145 | } as any); 146 | 147 | service.findById('1').subscribe({ 148 | next: (data) => { 149 | console.log(data); 150 | }, 151 | error: (error) => { 152 | expect(error).toBeDefined(); 153 | }, 154 | complete: done(), 155 | }); 156 | }); 157 | }); 158 | 159 | it('should save post', async () => { 160 | const toCreated = { 161 | title: 'test title', 162 | content: 'test content', 163 | }; 164 | 165 | const toReturned = { 166 | _id: '5ee49c3115a4e75254bb732e', 167 | ...toCreated, 168 | } as any; 169 | 170 | jest 171 | .spyOn(model, 'create') 172 | .mockImplementation(() => Promise.resolve([toReturned])); 173 | 174 | const data = await lastValueFrom(service.save(toCreated)); 175 | expect(data[0]._id).toBe('5ee49c3115a4e75254bb732e'); 176 | expect(model.create).toBeCalledWith({ 177 | ...toCreated, 178 | createdBy: { 179 | _id: 'dummyId', 180 | }, 181 | }); 182 | expect(model.create).toBeCalledTimes(1); 183 | }); 184 | 185 | describe('update', () => { 186 | it('perform update if post exists', (done) => { 187 | const toUpdated = { 188 | _id: '5ee49c3115a4e75254bb732e', 189 | title: 'test title', 190 | content: 'test content', 191 | }; 192 | 193 | jest.spyOn(model, 'findOneAndUpdate').mockReturnValue({ 194 | exec: jest.fn().mockResolvedValue(toUpdated) as any, 195 | } as any); 196 | 197 | service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({ 198 | next: (data) => { 199 | expect(data).toBeTruthy(); 200 | expect(model.findOneAndUpdate).toBeCalled(); 201 | }, 202 | error: (error) => console.log(error), 203 | complete: done(), 204 | }); 205 | }); 206 | 207 | it('throw an NotFoundException if post not exists', (done) => { 208 | const toUpdated = { 209 | _id: '5ee49c3115a4e75254bb732e', 210 | title: 'test title', 211 | content: 'test content', 212 | }; 213 | jest.spyOn(model, 'findOneAndUpdate').mockReturnValue({ 214 | exec: jest.fn().mockResolvedValue(null) as any, 215 | } as any); 216 | 217 | service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({ 218 | error: (error) => { 219 | expect(error).toBeDefined(); 220 | expect(model.findOneAndUpdate).toHaveBeenCalledTimes(1); 221 | }, 222 | complete: done(), 223 | }); 224 | }); 225 | }); 226 | 227 | describe('delete', () => { 228 | it('perform delete if post exists', (done) => { 229 | const toDeleted = { 230 | _id: '5ee49c3115a4e75254bb732e', 231 | title: 'test title', 232 | content: 'test content', 233 | }; 234 | jest.spyOn(model, 'findOneAndDelete').mockReturnValue({ 235 | exec: jest.fn().mockResolvedValueOnce(toDeleted), 236 | } as any); 237 | 238 | service.deleteById('anystring').subscribe({ 239 | next: (data) => { 240 | expect(data).toBeTruthy(); 241 | expect(model.findOneAndDelete).toBeCalled(); 242 | }, 243 | error: (error) => console.log(error), 244 | complete: done(), 245 | }); 246 | }); 247 | 248 | it('throw an NotFoundException if post not exists', (done) => { 249 | jest.spyOn(model, 'findOneAndDelete').mockReturnValue({ 250 | exec: jest.fn().mockResolvedValue(null), 251 | } as any); 252 | service.deleteById('anystring').subscribe({ 253 | error: (error) => { 254 | expect(error).toBeDefined(); 255 | expect(model.findOneAndDelete).toBeCalledTimes(1); 256 | }, 257 | complete: done(), 258 | }); 259 | }); 260 | }); 261 | 262 | it('should delete all post', (done) => { 263 | jest.spyOn(model, 'deleteMany').mockReturnValue({ 264 | exec: jest.fn().mockResolvedValueOnce({ 265 | deletedCount: 1, 266 | }), 267 | } as any); 268 | 269 | service.deleteAll().subscribe({ 270 | next: (data) => expect(data).toBeTruthy, 271 | error: (error) => console.log(error), 272 | complete: done(), 273 | }); 274 | }); 275 | 276 | it('should create comment ', async () => { 277 | const comment = { content: 'test' }; 278 | jest.spyOn(commentModel, 'create').mockImplementation(() => 279 | Promise.resolve({ 280 | ...comment, 281 | post: { _id: 'test' }, 282 | } as any), 283 | ); 284 | 285 | const result = await lastValueFrom( 286 | service.createCommentFor('test', comment), 287 | ); 288 | expect(result.content).toEqual('test'); 289 | expect(commentModel.create).toBeCalledWith({ 290 | ...comment, 291 | post: { _id: 'test' }, 292 | createdBy: { _id: 'dummyId' }, 293 | }); 294 | }); 295 | 296 | it('should get comments of post ', async () => { 297 | jest.spyOn(commentModel, 'find').mockImplementation(() => { 298 | return { 299 | select: jest.fn().mockReturnValue({ 300 | exec: jest.fn().mockResolvedValue([ 301 | { 302 | _id: 'test', 303 | content: 'content', 304 | post: { _id: '_test_id' }, 305 | }, 306 | ] as any), 307 | }), 308 | } as any; 309 | }); 310 | 311 | const result = await lastValueFrom(service.commentsOf('test')); 312 | expect(result.length).toBe(1); 313 | expect(result[0].content).toEqual('content'); 314 | expect(commentModel.find).toBeCalledWith({ post: { _id: 'test' } }); 315 | }); 316 | }); 317 | -------------------------------------------------------------------------------- /src/post/post.service.stub.ts: -------------------------------------------------------------------------------- 1 | import { of, Observable } from 'rxjs'; 2 | import { CreatePostDto } from './create-post.dto'; 3 | import { UpdatePostDto } from './update-post.dto'; 4 | import { PostService } from './post.service'; 5 | import { Post } from '../database/post.model'; 6 | import { CreateCommentDto } from './create-comment.dto'; 7 | import { Comment } from '../database/comment.model'; 8 | 9 | // To unite the method signature of the mocked PostServiceStub and PostService, 10 | // use `Pick` instead of writing an extra interface. 11 | // see: https://dev.to/jonrimmer/typesafe-mocking-in-typescript-3b50 12 | // also see: https://www.typescriptlang.org/docs/handbook/utility-types.html#picktk 13 | export class PostServiceStub implements Pick { 14 | private posts: Post[] = [ 15 | { 16 | _id: '5ee49c3115a4e75254bb732e', 17 | title: 'Generate a NestJS project', 18 | content: 'content', 19 | } as Post, 20 | { 21 | _id: '5ee49c3115a4e75254bb732f', 22 | title: 'Create CRUD RESTful APIs', 23 | content: 'content', 24 | } as Post, 25 | { 26 | _id: '5ee49c3115a4e75254bb7330', 27 | title: 'Connect to MongoDB', 28 | content: 'content', 29 | } as Post, 30 | ]; 31 | 32 | private comments: Comment[] = [ 33 | { 34 | post: { _id: '5ee49c3115a4e75254bb732e' }, 35 | content: 'comment of post', 36 | } as Comment, 37 | ]; 38 | 39 | findAll(): Observable { 40 | return of(this.posts); 41 | } 42 | 43 | findById(id: string): Observable { 44 | const { title, content } = this.posts[0]; 45 | return of({ _id: id, title, content } as Post); 46 | } 47 | 48 | save(data: CreatePostDto): Observable { 49 | return of({ _id: this.posts[0]._id, ...data } as Post); 50 | } 51 | 52 | update(id: string, data: UpdatePostDto): Observable { 53 | return of({ _id: id, ...data } as Post); 54 | } 55 | 56 | deleteById(id: string): Observable { 57 | return of({ _id: id, title:'test title', content:'content' } as Post); 58 | } 59 | 60 | deleteAll(): Observable { 61 | throw new Error('Method not implemented.'); 62 | } 63 | 64 | createCommentFor( 65 | postid: string, 66 | data: CreateCommentDto, 67 | ): Observable { 68 | return of({ id: 'test', post: { _id: postid }, ...data } as Comment); 69 | } 70 | 71 | commentsOf(id: string): Observable { 72 | return of(this.comments); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/post/post.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; 2 | import { REQUEST } from '@nestjs/core'; 3 | import { Model } from 'mongoose'; 4 | import { EMPTY, from, Observable, of } from 'rxjs'; 5 | import { mergeMap, throwIfEmpty } from 'rxjs/operators'; 6 | import { AuthenticatedRequest } from '../auth/interface/authenticated-request.interface'; 7 | import { Comment } from '../database/comment.model'; 8 | import { COMMENT_MODEL, POST_MODEL } from '../database/database.constants'; 9 | import { Post } from '../database/post.model'; 10 | import { CreateCommentDto } from './create-comment.dto'; 11 | import { CreatePostDto } from './create-post.dto'; 12 | import { UpdatePostDto } from './update-post.dto'; 13 | 14 | @Injectable({ scope: Scope.REQUEST }) 15 | export class PostService { 16 | constructor( 17 | @Inject(POST_MODEL) private readonly postModel: Model, 18 | @Inject(COMMENT_MODEL) private readonly commentModel: Model, 19 | @Inject(REQUEST) private readonly req: AuthenticatedRequest, 20 | ) {} 21 | 22 | findAll(keyword?: string, skip = 0, limit = 10): Observable { 23 | if (keyword) { 24 | return from( 25 | this.postModel 26 | .find({ title: { $regex: '.*' + keyword + '.*' } }) 27 | .skip(skip) 28 | .limit(limit) 29 | .exec(), 30 | ); 31 | } else { 32 | return from(this.postModel.find({}).skip(skip).limit(limit).exec()); 33 | } 34 | } 35 | 36 | findById(id: string): Observable { 37 | return from(this.postModel.findOne({ _id: id }).exec()).pipe( 38 | mergeMap((p) => (p ? of(p) : EMPTY)), 39 | throwIfEmpty(() => new NotFoundException(`post:$id was not found`)), 40 | ); 41 | } 42 | 43 | save(data: CreatePostDto): Observable { 44 | const createPost: Promise = this.postModel.create({ 45 | ...data, 46 | createdBy: { _id: this.req.user.id }, 47 | }); 48 | return from(createPost); 49 | } 50 | 51 | update(id: string, data: UpdatePostDto): Observable { 52 | return from( 53 | this.postModel 54 | .findOneAndUpdate( 55 | { _id: id }, 56 | { ...data, updatedBy: { _id: this.req.user.id } }, 57 | { new: true }, 58 | ) 59 | .exec(), 60 | ).pipe( 61 | mergeMap((p) => (p ? of(p) : EMPTY)), 62 | throwIfEmpty(() => new NotFoundException(`post:$id was not found`)), 63 | ); 64 | } 65 | 66 | deleteById(id: string): Observable { 67 | return from(this.postModel.findOneAndDelete({ _id: id }).exec()).pipe( 68 | mergeMap((p) => (p ? of(p) : EMPTY)), 69 | throwIfEmpty(() => new NotFoundException(`post:$id was not found`)), 70 | ); 71 | } 72 | 73 | deleteAll(): Observable { 74 | return from(this.postModel.deleteMany({}).exec()); 75 | } 76 | 77 | // actions for comments 78 | createCommentFor(id: string, data: CreateCommentDto): Observable { 79 | const createdComment: Promise = this.commentModel.create({ 80 | post: { _id: id }, 81 | ...data, 82 | createdBy: { _id: this.req.user.id }, 83 | }); 84 | return from(createdComment); 85 | } 86 | 87 | commentsOf(id: string): Observable { 88 | const comments = this.commentModel 89 | .find({ 90 | post: { _id: id }, 91 | }) 92 | .select('-post') 93 | .exec(); 94 | return from(comments); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/post/update-post.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from "class-validator"; 2 | 3 | export class UpdatePostDto { 4 | 5 | @IsNotEmpty() 6 | readonly title: string; 7 | 8 | @IsNotEmpty() 9 | readonly content: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/sendgrid/sendgrid.constants.ts: -------------------------------------------------------------------------------- 1 | export const SENDGRID_MAIL = 'SENDGRID_MAIL'; 2 | -------------------------------------------------------------------------------- /src/sendgrid/sendgrid.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { sendgridProviders } from './sendgrid.providers'; 3 | import { SendgridService } from './sendgrid.service'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import sendgridConfig from '../config/sendgrid.config'; 6 | 7 | @Module({ 8 | imports: [ConfigModule.forFeature(sendgridConfig)], 9 | providers: [...sendgridProviders, SendgridService], 10 | exports: [...sendgridProviders, SendgridService] 11 | }) 12 | export class SendgridModule { } 13 | -------------------------------------------------------------------------------- /src/sendgrid/sendgrid.providers.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { MailService } from '@sendgrid/mail'; 4 | import sendgridConfig from '../config/sendgrid.config'; 5 | import { SENDGRID_MAIL } from './sendgrid.constants'; 6 | import { sendgridProviders } from './sendgrid.providers'; 7 | 8 | describe('SendgridProviders', () => { 9 | let provider: MailService; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | imports: [ConfigModule.forFeature(sendgridConfig)], 14 | providers: [...sendgridProviders], 15 | }).compile(); 16 | 17 | provider = module.get(SENDGRID_MAIL); 18 | }); 19 | 20 | it('should be defined', () => { 21 | expect(provider).toBeDefined(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/sendgrid/sendgrid.providers.ts: -------------------------------------------------------------------------------- 1 | import { ConfigType } from '@nestjs/config'; 2 | import { MailService } from '@sendgrid/mail'; 3 | import sendgridConfig from '../config/sendgrid.config'; 4 | import { SENDGRID_MAIL } from './sendgrid.constants'; 5 | 6 | export const sendgridProviders = [ 7 | { 8 | provide: SENDGRID_MAIL, 9 | useFactory: (config: ConfigType): MailService => 10 | { 11 | const mail = new MailService(); 12 | mail.setApiKey(config.apiKey); 13 | mail.setTimeout(5000); 14 | //mail.setTwilioEmailAuth(username, password) 15 | return mail; 16 | }, 17 | inject: [sendgridConfig.KEY], 18 | } 19 | ]; 20 | -------------------------------------------------------------------------------- /src/sendgrid/sendgrid.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MailService } from '@sendgrid/mail'; 3 | import { lastValueFrom } from 'rxjs'; 4 | import { SENDGRID_MAIL } from './sendgrid.constants'; 5 | import { SendgridService } from './sendgrid.service'; 6 | 7 | describe('SendgridService', () => { 8 | let service: SendgridService; 9 | let mailService: MailService; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | SendgridService, 15 | { 16 | provide: SENDGRID_MAIL, 17 | useValue: { 18 | send: jest.fn(), 19 | }, 20 | }, 21 | ], 22 | }).compile(); 23 | 24 | service = module.get(SendgridService); 25 | mailService = module.get(SENDGRID_MAIL); 26 | }); 27 | 28 | it('should be defined', () => { 29 | expect(service).toBeDefined(); 30 | }); 31 | 32 | it('MailService should be defined', () => { 33 | expect(mailService).toBeDefined(); 34 | }); 35 | 36 | it('should call MailService.send', async () => { 37 | const msg = { 38 | to: 'test@example.com', 39 | from: 'test@example.com', // Use the email address or domain you verified above 40 | subject: 'Sending with Twilio SendGrid is Fun', 41 | text: 'and easy to do anywhere, even with Node.js', 42 | html: 'and easy to do anywhere, even with Node.js', 43 | }; 44 | 45 | const sendSpy = jest 46 | .spyOn(mailService, 'send') 47 | .mockResolvedValue({} as any); 48 | 49 | await lastValueFrom(service.send(msg)); 50 | expect(sendSpy).toBeCalledTimes(1); 51 | expect(sendSpy).toBeCalledWith(msg, false); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/sendgrid/sendgrid.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { MailService, MailDataRequired } from '@sendgrid/mail'; 3 | import { SENDGRID_MAIL } from './sendgrid.constants'; 4 | import { Observable, from } from 'rxjs'; 5 | 6 | @Injectable() 7 | export class SendgridService { 8 | 9 | constructor(@Inject(SENDGRID_MAIL) private mailService: MailService) { } 10 | 11 | send(data: MailDataRequired): Observable{ 12 | //console.log(this.mailService) 13 | return from(this.mailService.send(data, false)) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/shared/enum/role-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum RoleType { 2 | ADMIN = 'ADMIN', 3 | USER = 'USER', 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/pipe/parse-object-id.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { ParseObjectIdPipe } from './parse-object-id.pipe'; 3 | 4 | describe('ParseObjectIdPipe', () => { 5 | let isObjectId; 6 | 7 | beforeEach(() => { 8 | isObjectId = new ParseObjectIdPipe(); 9 | }); 10 | 11 | it('should be defined', () => { 12 | expect(isObjectId).toBeDefined(); 13 | }); 14 | 15 | it('if valid', () => { 16 | const validId = new mongoose.Types.ObjectId().toHexString(); 17 | const result = isObjectId.transform(validId, {} as any); 18 | expect(result).toEqual(validId); 19 | }); 20 | 21 | it('if invalid', () => { 22 | try { 23 | const result = isObjectId.transform('anerror', {} as any); 24 | } catch (e) { 25 | expect(e).not.toBeNull(); 26 | } 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/shared/pipe/parse-object-id.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | BadRequestException, 4 | Injectable, 5 | PipeTransform 6 | } from '@nestjs/common'; 7 | import * as mongoose from 'mongoose'; 8 | 9 | @Injectable() 10 | export class ParseObjectIdPipe implements PipeTransform { 11 | transform(value: string, metadata: ArgumentMetadata) { 12 | if (!mongoose.isValidObjectId(value)) { 13 | throw new BadRequestException(`$value is not a valid mongoose object id`); 14 | } 15 | return value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/user/profile.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ProfileController } from './profile.controller'; 3 | import { AuthenticatedRequest } from '../auth/interface/authenticated-request.interface'; 4 | 5 | describe('ProfileController', () => { 6 | let controller: ProfileController; 7 | beforeEach(async () => { 8 | const app: TestingModule = await Test.createTestingModule({ 9 | controllers: [ProfileController], 10 | }).compile(); 11 | 12 | controller = app.get(ProfileController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | 19 | it('should call req', async () => { 20 | const req = {user :{username:'test'}} as AuthenticatedRequest; 21 | expect(controller.getProfile(req).username).toBe('test'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/user/profile.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Req, UseGuards } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | import { JwtAuthGuard } from '../auth/guard/jwt-auth.guard'; 4 | 5 | @Controller() 6 | export class ProfileController { 7 | 8 | @UseGuards(JwtAuthGuard) 9 | @Get('profile') 10 | getProfile(@Req() req: Request): any { 11 | return req.user; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/user/register.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { RegisterController } from './register.controller'; 3 | import { UserService } from './user.service'; 4 | import { of, lastValueFrom } from 'rxjs'; 5 | import { User } from 'database/user.model'; 6 | import { RegisterDto } from './register.dto'; 7 | 8 | describe('Register Controller', () => { 9 | let controller: RegisterController; 10 | let service: UserService; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | controllers: [RegisterController], 15 | providers: [ 16 | { 17 | provide: UserService, 18 | useValue: { 19 | register: jest.fn(), 20 | existsByUsername: jest.fn(), 21 | existsByEmail: jest.fn(), 22 | }, 23 | }, 24 | ], 25 | }).compile(); 26 | 27 | controller = module.get(RegisterController); 28 | service = module.get(UserService); 29 | }); 30 | 31 | it('should be defined', () => { 32 | expect(controller).toBeDefined(); 33 | }); 34 | 35 | describe('register', () => { 36 | it('should throw ConflictException when username is existed ', async () => { 37 | const existsByUsernameSpy = jest 38 | .spyOn(service, 'existsByUsername') 39 | .mockReturnValue(of(true)); 40 | const existsByEmailSpy = jest 41 | .spyOn(service, 'existsByEmail') 42 | .mockReturnValue(of(true)); 43 | const saveSpy = jest 44 | .spyOn(service, 'register') 45 | .mockReturnValue(of({} as User)); 46 | 47 | const responseMock = { 48 | location: jest.fn().mockReturnThis(), 49 | json: jest.fn().mockReturnThis(), 50 | send: jest.fn().mockReturnThis(), 51 | } as any; 52 | try { 53 | await lastValueFrom( 54 | controller.register( 55 | { username: 'hantsy' } as RegisterDto, 56 | responseMock, 57 | ), 58 | ); 59 | } catch (e) { 60 | expect(e).toBeDefined(); 61 | expect(existsByUsernameSpy).toBeCalledWith('hantsy'); 62 | expect(existsByEmailSpy).toBeCalledTimes(0); 63 | expect(saveSpy).toBeCalledTimes(0); 64 | } 65 | }); 66 | 67 | it('should throw ConflictException when email is existed ', async () => { 68 | const existsByUsernameSpy = jest 69 | .spyOn(service, 'existsByUsername') 70 | .mockReturnValue(of(false)); 71 | const existsByEmailSpy = jest 72 | .spyOn(service, 'existsByEmail') 73 | .mockReturnValue(of(true)); 74 | const saveSpy = jest 75 | .spyOn(service, 'register') 76 | .mockReturnValue(of({} as User)); 77 | 78 | const responseMock = { 79 | location: jest.fn().mockReturnThis(), 80 | json: jest.fn().mockReturnThis(), 81 | send: jest.fn().mockReturnThis(), 82 | } as any; 83 | try { 84 | await lastValueFrom( 85 | controller.register( 86 | { username: 'hantsy', email: 'hantsy@example.com' } as RegisterDto, 87 | responseMock, 88 | ), 89 | ); 90 | } catch (e) { 91 | expect(e).toBeDefined(); 92 | expect(existsByUsernameSpy).toBeCalledWith('hantsy'); 93 | expect(existsByEmailSpy).toBeCalledWith('hantsy@example.com'); 94 | expect(saveSpy).toBeCalledTimes(0); 95 | } 96 | }); 97 | 98 | it('should save when username and email are available ', async () => { 99 | const existsByUsernameSpy = jest 100 | .spyOn(service, 'existsByUsername') 101 | .mockReturnValue(of(false)); 102 | const existsByEmailSpy = jest 103 | .spyOn(service, 'existsByEmail') 104 | .mockReturnValue(of(false)); 105 | const saveSpy = jest 106 | .spyOn(service, 'register') 107 | .mockReturnValue(of({ _id: '123' } as User)); 108 | 109 | const responseMock = { 110 | location: jest.fn().mockReturnThis(), 111 | status: jest.fn().mockReturnThis(), 112 | send: jest.fn().mockReturnThis(), 113 | } as any; 114 | 115 | const locationSpy = jest.spyOn(responseMock, 'location'); 116 | const statusSpy = jest.spyOn(responseMock, 'status'); 117 | const sendSpy = jest.spyOn(responseMock, 'send'); 118 | 119 | await lastValueFrom( 120 | controller.register( 121 | { username: 'hantsy', email: 'hantsy@example.com' } as RegisterDto, 122 | responseMock, 123 | ), 124 | ); 125 | 126 | expect(existsByUsernameSpy).toBeCalledWith('hantsy'); 127 | expect(existsByEmailSpy).toBeCalledWith('hantsy@example.com'); 128 | expect(saveSpy).toBeCalledTimes(1); 129 | expect(locationSpy).toBeCalled(); 130 | expect(statusSpy).toBeCalled(); 131 | expect(sendSpy).toBeCalled(); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/user/register.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, ConflictException, Controller, Post, Res } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { Observable } from 'rxjs'; 4 | import { mergeMap, map } from 'rxjs/operators'; 5 | import { RegisterDto } from './register.dto'; 6 | import { UserService } from './user.service'; 7 | 8 | @Controller('register') 9 | export class RegisterController { 10 | constructor(private userService: UserService) { } 11 | 12 | @Post() 13 | register( 14 | @Body() registerDto: RegisterDto, 15 | @Res() res: Response): Observable { 16 | const username = registerDto.username; 17 | 18 | return this.userService.existsByUsername(username).pipe( 19 | mergeMap(exists => { 20 | if (exists) { 21 | throw new ConflictException(`username:${username} is existed`) 22 | } 23 | else { 24 | const email = registerDto.email; 25 | return this.userService.existsByEmail(email).pipe( 26 | mergeMap(exists => { 27 | if (exists) { 28 | throw new ConflictException(`email:${email} is existed`) 29 | } 30 | else { 31 | return this.userService.register(registerDto).pipe( 32 | map(user => 33 | res.location('/users/' + user.id) 34 | .status(201) 35 | .send() 36 | ) 37 | ); 38 | } 39 | }) 40 | ); 41 | } 42 | }) 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/user/register.dto.spec.ts: -------------------------------------------------------------------------------- 1 | import { RegisterDto } from './register.dto'; 2 | 3 | describe('RegisterDto', () => { 4 | it('should be defined', () => { 5 | expect(new RegisterDto()).toBeDefined(); 6 | }); 7 | 8 | it('should equals', () => { 9 | 10 | const dto: RegisterDto = { 11 | username: 'hantsy', 12 | password: 'password', 13 | firstName: 'Hantsy', 14 | lastName: 'Bai', 15 | email: 'hantsy@gmail.com' 16 | }; 17 | 18 | expect(dto).toEqual( 19 | { 20 | username: 'hantsy', 21 | password: 'password', 22 | firstName: 'Hantsy', 23 | lastName: 'Bai', 24 | email: 'hantsy@gmail.com' 25 | } 26 | ); 27 | 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/user/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class RegisterDto { 4 | @IsNotEmpty() 5 | readonly username: string; 6 | 7 | @IsNotEmpty() 8 | @IsEmail() 9 | //@Matches(/^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i) 10 | readonly email: string; 11 | 12 | @IsNotEmpty() 13 | @MinLength(8, { message: " The min length of password is 8 " }) 14 | @MaxLength(20, { message: " The password can't accept more than 20 characters " }) 15 | // @Matches(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])[0-9a-zA-Z]{8,20}$/, 16 | // { message: " A password at least contains one numeric digit, one supercase char and one lowercase char" } 17 | // ) 18 | readonly password: string; 19 | 20 | @IsNotEmpty() 21 | readonly firstName?: string; 22 | 23 | @IsNotEmpty() 24 | readonly lastName?: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/user/user-data-initializer.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Inject, 3 | Injectable, 4 | OnModuleInit 5 | } from '@nestjs/common'; 6 | import { Model } from 'mongoose'; 7 | import { USER_MODEL } from '../database/database.constants'; 8 | import { RoleType } from '../shared/enum/role-type.enum'; 9 | import { User } from '../database/user.model'; 10 | 11 | @Injectable() 12 | export class UserDataInitializerService 13 | implements OnModuleInit { 14 | constructor(@Inject(USER_MODEL) private userModel: Model) { } 15 | 16 | async onModuleInit(): Promise { 17 | console.log('(UserModule) is initialized...'); 18 | await this.userModel.deleteMany({}); 19 | const user = { 20 | username: 'hantsy', 21 | password: 'password', 22 | email: 'hantsy@example.com', 23 | roles: [RoleType.USER], 24 | }; 25 | 26 | const admin = { 27 | username: 'admin', 28 | password: 'password', 29 | email: 'admin@example.com', 30 | roles: [RoleType.ADMIN], 31 | }; 32 | await Promise.all( 33 | [ 34 | this.userModel.create(user), 35 | this.userModel.create(admin) 36 | ] 37 | ).then( 38 | data => console.log(data) 39 | ); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | import { UserService } from './user.service'; 4 | import { lastValueFrom, of } from 'rxjs'; 5 | 6 | describe('UserController', () => { 7 | let controller: UserController; 8 | let service: UserService; 9 | beforeEach(async () => { 10 | const app: TestingModule = await Test.createTestingModule({ 11 | providers: [ 12 | { 13 | provide: UserService, 14 | useValue: { 15 | findById: jest.fn(), 16 | }, 17 | }, 18 | ], 19 | controllers: [UserController], 20 | }).compile(); 21 | 22 | controller = app.get(UserController); 23 | service = app.get(UserService); 24 | }); 25 | 26 | it('should be defined', () => { 27 | expect(controller).toBeDefined(); 28 | }); 29 | 30 | it('getUser', async () => { 31 | jest 32 | .spyOn(service, 'findById') 33 | .mockImplementationOnce((id: string, withPosts: boolean) => 34 | of({ 35 | username: 'hantsy', 36 | password: 'mysecret', 37 | email: 'hantsy@example.com', 38 | firstName: 'hantsy', 39 | lastName: 'bai', 40 | } as any), 41 | ); 42 | const user = await lastValueFrom(controller.getUser('id', false)); 43 | expect(user.firstName).toBe('hantsy'); 44 | expect(user.lastName).toBe('bai'); 45 | expect(service.findById).toBeCalledWith('id', false); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, DefaultValuePipe, Get, Param, Query } from '@nestjs/common'; 2 | import { User } from 'database/user.model'; 3 | import { Observable } from 'rxjs'; 4 | import { ParseObjectIdPipe } from '../shared/pipe/parse-object-id.pipe'; 5 | import { UserService } from './user.service'; 6 | 7 | @Controller({ path: "/users" }) 8 | export class UserController { 9 | 10 | constructor(private userService: UserService) { } 11 | 12 | @Get(':id') 13 | getUser( 14 | @Param('id', ParseObjectIdPipe) id: string, 15 | @Query('withPosts', new DefaultValuePipe(false)) withPosts?: boolean 16 | ): Observable> { 17 | return this.userService.findById(id, withPosts); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/user/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { RoleType } from '../shared/enum/role-type.enum'; 2 | 3 | export class UserDto { 4 | readonly id: string; 5 | readonly username: string; 6 | readonly email: string; 7 | readonly password: string; 8 | readonly name?: string; 9 | readonly firstName?: string; 10 | readonly lastName?: string; 11 | readonly roles?: RoleType[]; 12 | readonly createdAt?: Date; 13 | readonly updatedAt?: Date; 14 | } 15 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DatabaseModule } from '../database/database.module'; 3 | import { ProfileController } from './profile.controller'; 4 | import { UserDataInitializerService } from './user-data-initializer.service'; 5 | import { UserService } from './user.service'; 6 | import { UserController } from './user.controller'; 7 | import { RegisterController } from './register.controller'; 8 | import { SendgridModule } from '../sendgrid/sendgrid.module'; 9 | @Module({ 10 | imports: [DatabaseModule, SendgridModule], 11 | providers: [UserService, UserDataInitializerService], 12 | exports: [UserService], 13 | controllers: [ProfileController, UserController, RegisterController], 14 | }) 15 | export class UserModule {} 16 | -------------------------------------------------------------------------------- /src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Model, FilterQuery } from 'mongoose'; 3 | import { lastValueFrom, of } from 'rxjs'; 4 | 5 | import { USER_MODEL } from '../database/database.constants'; 6 | import { User } from '../database/user.model'; 7 | import { SendgridService } from '../sendgrid/sendgrid.service'; 8 | import { RoleType } from '../shared/enum/role-type.enum'; 9 | import { UserService } from './user.service'; 10 | 11 | describe('UserService', () => { 12 | let service: UserService; 13 | let model: Model; 14 | let sendgrid: SendgridService; 15 | 16 | beforeEach(async () => { 17 | const module: TestingModule = await Test.createTestingModule({ 18 | providers: [ 19 | UserService, 20 | { 21 | provide: USER_MODEL, 22 | useValue: { 23 | findOne: jest.fn(), 24 | exists: jest.fn(), 25 | create: jest.fn(), 26 | }, 27 | }, 28 | { 29 | provide: SendgridService, 30 | useValue: { 31 | send: jest.fn(), 32 | }, 33 | }, 34 | ], 35 | }).compile(); 36 | 37 | service = module.get(UserService); 38 | sendgrid = module.get(SendgridService); 39 | model = module.get>(USER_MODEL); 40 | }); 41 | 42 | it('should be defined', () => { 43 | expect(service).toBeDefined(); 44 | }); 45 | 46 | it('save ', async () => { 47 | const sampleData = { 48 | username: 'hantsy', 49 | email: 'hantsy@example.com', 50 | firstName: 'hantsy', 51 | lastName: 'bai', 52 | password: 'mysecret', 53 | }; 54 | 55 | const msg = { 56 | from: 'service@example.com', // Use the email address or domain you verified above 57 | subject: 'Welcome to Nestjs Sample', 58 | templateId: 'welcome', 59 | personalizations: [ 60 | { 61 | to: 'hantsy@example.com', 62 | dynamicTemplateData: { name: 'hantsy bai' }, 63 | }, 64 | ], 65 | }; 66 | 67 | const saveSpy = jest.spyOn(model, 'create').mockImplementation(() => 68 | Promise.resolve({ 69 | _id: '123', 70 | ...sampleData, 71 | } as any), 72 | ); 73 | 74 | const pipeMock = { 75 | pipe: jest.fn(), 76 | }; 77 | 78 | const pipeSpy = jest.spyOn(pipeMock, 'pipe'); 79 | 80 | const sendSpy = jest 81 | .spyOn(sendgrid, 'send') 82 | .mockImplementation((data: any) => { 83 | return of(pipeMock); 84 | }); 85 | 86 | const result = await lastValueFrom(service.register(sampleData)); 87 | expect(saveSpy).toBeCalledWith({ ...sampleData, roles: [RoleType.USER] }); 88 | expect(result._id).toBeDefined(); 89 | //expect(sendSpy).toBeCalledWith(msg); 90 | //expect(pipeSpy).toBeCalled(); 91 | }); 92 | 93 | it('findByUsername should return user', async () => { 94 | jest.spyOn(model, 'findOne').mockImplementation( 95 | (filter?: FilterQuery) => 96 | ({ 97 | exec: jest.fn().mockResolvedValue({ 98 | username: 'hantsy', 99 | email: 'hantsy@example.com', 100 | } as User), 101 | } as any), 102 | ); 103 | 104 | const foundUser = await lastValueFrom(service.findByUsername('hantsy')); 105 | expect(foundUser).toEqual({ 106 | username: 'hantsy', 107 | email: 'hantsy@example.com', 108 | }); 109 | expect(model.findOne).lastCalledWith({ username: 'hantsy' }); 110 | expect(model.findOne).toBeCalledTimes(1); 111 | }); 112 | 113 | describe('findById', () => { 114 | it('return one result', async () => { 115 | jest.spyOn(model, 'findOne') 116 | .mockImplementation( 117 | (filter?: FilterQuery) => 118 | ({ 119 | exec: jest.fn().mockResolvedValue({ 120 | username: 'hantsy', 121 | email: 'hantsy@example.com', 122 | } as User), 123 | } as any), 124 | ); 125 | 126 | const foundUser = await lastValueFrom(service.findById('hantsy')); 127 | expect(foundUser).toEqual({ 128 | username: 'hantsy', 129 | email: 'hantsy@example.com', 130 | }); 131 | expect(model.findOne).lastCalledWith({ _id: 'hantsy' }); 132 | expect(model.findOne).toBeCalledTimes(1); 133 | }); 134 | 135 | it('return a null result', async () => { 136 | jest 137 | .spyOn(model, 'findOne') 138 | .mockImplementation((filter?: FilterQuery) => ({ 139 | exec: jest.fn().mockResolvedValue(null) as any, 140 | } as any)); 141 | 142 | try { 143 | const foundUser = await lastValueFrom(service.findById('hantsy')); 144 | } catch (e) { 145 | expect(e).toBeDefined(); 146 | } 147 | }); 148 | 149 | it('parameter withPosts=true', async () => { 150 | jest 151 | .spyOn(model, 'findOne') 152 | .mockImplementation((filter?: FilterQuery) => ({ 153 | populate: jest.fn().mockReturnThis(), 154 | exec: jest.fn().mockResolvedValue({ 155 | username: 'hantsy', 156 | email: 'hantsy@example.com', 157 | } as User), 158 | } as any)); 159 | 160 | const foundUser = await lastValueFrom(service.findById('hantsy', true)); 161 | expect(foundUser).toEqual({ 162 | username: 'hantsy', 163 | email: 'hantsy@example.com', 164 | }); 165 | expect(model.findOne).lastCalledWith({ _id: 'hantsy' }); 166 | expect(model.findOne).toBeCalledTimes(1); 167 | }); 168 | }); 169 | 170 | describe('existsByUsername', () => { 171 | it('should return true if exists ', async () => { 172 | const existsSpy = jest 173 | .spyOn(model, 'exists') 174 | .mockImplementation((filter: any) => { 175 | return { 176 | exec: jest.fn().mockResolvedValue({ 177 | _id: 'test', 178 | } as any), 179 | } as any; 180 | }); 181 | const result = await lastValueFrom(service.existsByUsername('hantsy')); 182 | 183 | expect(existsSpy).toBeCalledWith({ username: 'hantsy' }); 184 | expect(existsSpy).toBeCalledTimes(1); 185 | expect(result).toBeTruthy(); 186 | }); 187 | 188 | it('should return false if not exists ', async () => { 189 | const existsSpy = jest 190 | .spyOn(model, 'exists') 191 | .mockImplementation((filter: any) => { 192 | return { 193 | exec: jest.fn().mockResolvedValue(null), 194 | } as any; 195 | }); 196 | const result = await lastValueFrom(service.existsByUsername('hantsy')); 197 | 198 | expect(existsSpy).toBeCalledWith({ username: 'hantsy' }); 199 | expect(existsSpy).toBeCalledTimes(1); 200 | expect(result).toBeFalsy(); 201 | }); 202 | }); 203 | 204 | describe('existsByEmail', () => { 205 | it('should return true if exists ', async () => { 206 | const existsSpy = jest 207 | .spyOn(model, 'exists') 208 | .mockImplementation((filter: any) => { 209 | return { 210 | exec: jest.fn().mockResolvedValue({ 211 | _id: 'test', 212 | } as any), 213 | } as any; 214 | }); 215 | const result = await lastValueFrom( 216 | service.existsByEmail('hantsy@example.com'), 217 | ); 218 | 219 | expect(existsSpy).toBeCalledWith({ email: 'hantsy@example.com' }); 220 | expect(existsSpy).toBeCalledTimes(1); 221 | expect(result).toBeTruthy(); 222 | }); 223 | 224 | it('should return false if not exists ', async () => { 225 | const existsSpy = jest 226 | .spyOn(model, 'exists') 227 | .mockImplementation((filter: any) => { 228 | return { 229 | exec: jest.fn().mockResolvedValue(null), 230 | } as any; 231 | }); 232 | const result = await lastValueFrom( 233 | service.existsByEmail('hantsy@example.com'), 234 | ); 235 | 236 | expect(existsSpy).toBeCalledWith({ email: 'hantsy@example.com' }); 237 | expect(existsSpy).toBeCalledTimes(1); 238 | expect(result).toBeFalsy(); 239 | }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { EMPTY, from, Observable, of, throwError } from 'rxjs'; 3 | import { mergeMap, tap, throwIfEmpty, catchError, map } from 'rxjs/operators'; 4 | import { RoleType } from '../shared/enum/role-type.enum'; 5 | import { USER_MODEL } from '../database/database.constants'; 6 | import { User, UserModel } from '../database/user.model'; 7 | import { SendgridService } from '../sendgrid/sendgrid.service'; 8 | import { RegisterDto } from './register.dto'; 9 | 10 | @Injectable() 11 | export class UserService { 12 | constructor( 13 | @Inject(USER_MODEL) private userModel: UserModel, 14 | private sendgridService: SendgridService, 15 | ) {} 16 | 17 | findByUsername(username: string): Observable { 18 | return from(this.userModel.findOne({ username }).exec()); 19 | } 20 | 21 | // since mongoose 6.2, `Model.exists` is chagned to return a lean document with `_id` or `null` 22 | existsByUsername(username: string): Observable { 23 | return from(this.userModel.exists({ username }).exec()).pipe( 24 | map((exists) => exists != null), 25 | ); 26 | } 27 | 28 | existsByEmail(email: string): Observable { 29 | return from(this.userModel.exists({ email }).exec()).pipe( 30 | map((exists) => exists != null), 31 | ); 32 | } 33 | 34 | register(data: RegisterDto): Observable { 35 | // Simply here we can send a verification email to the new registered user 36 | // by calling SendGrid directly. 37 | // 38 | // In a microservice application, you can send this msg to a message broker 39 | // then subsribe it in antoher (micro)service and send the emails. 40 | 41 | // Use base64 to genrate a random string 42 | // const randomCode = btoa(Math.random().toString()).slice(0, 4); 43 | // console.log(`random code:${randomCode}`); 44 | 45 | // const created = this.userModel.create({ 46 | // ...data, 47 | // verified: false, 48 | // verifyCode: randomCode, 49 | // roles: [RoleType.USER] 50 | // }); 51 | 52 | // Sendgrid can manage email templates, use an existing template is more reasonable. 53 | // 54 | // const msg = { 55 | // to: data.email, 56 | // from: 'no-reply@example.com', // Use the email address or domain you verified above 57 | // subject: 'Welcome to Nestjs Sample', 58 | // text: `verification code:${randomCode}`, 59 | // html: `verification code:${randomCode}`, 60 | // }; 61 | // this.sendgridService.send(msg) 62 | // .subscribe({ 63 | // next: data => console.log(`${data}`), 64 | // error: error => console.log(`${error}`) 65 | // }); 66 | 67 | const created = this.userModel.create({ 68 | ...data, 69 | roles: [RoleType.USER], 70 | }); 71 | 72 | return from(created); 73 | 74 | // const msg = { 75 | // from: 'hantsy@gmail.com', // Use the email address or domain you verified above 76 | // subject: 'Welcome to Nestjs Sample', 77 | // templateId: "d-cc6080999ac04a558d632acf2d5d0b7a", 78 | // personalizations: [ 79 | // { 80 | // to: data.email, 81 | // dynamicTemplateData: { name: data.firstName + ' ' + data.lastName }, 82 | // } 83 | // ] 84 | 85 | // }; 86 | // return this.sendgridService.send(msg).pipe( 87 | // catchError(err=>of(`sending email failed:${err}`)), 88 | // tap(data => console.log(data)), 89 | // mergeMap(data => from(created)), 90 | // ); 91 | } 92 | 93 | findById(id: string, withPosts = false): Observable { 94 | const userQuery = this.userModel.findOne({ _id: id }); 95 | if (withPosts) { 96 | userQuery.populate('posts'); 97 | } 98 | return from(userQuery.exec()).pipe( 99 | mergeMap((p) => (p ? of(p) : EMPTY)), 100 | throwIfEmpty(() => new NotFoundException(`user:${id} was not found`)), 101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import * as mongoose from 'mongoose'; 4 | import * as request from 'supertest'; 5 | import { AppModule } from './../src/app.module'; 6 | 7 | describe('API endpoints testing (e2e)', () => { 8 | let app: INestApplication; 9 | 10 | beforeAll(async () => { 11 | const moduleFixture: TestingModule = await Test.createTestingModule({ 12 | imports: [AppModule], 13 | }).compile(); 14 | 15 | app = moduleFixture.createNestApplication(); 16 | app.enableShutdownHooks(); 17 | app.useGlobalPipes(new ValidationPipe()); 18 | await app.init(); 19 | }); 20 | 21 | afterAll(async () => { 22 | await app.close(); 23 | }); 24 | 25 | describe('/register a new user', () => { 26 | it('if username is existed', async () => { 27 | const res = await request(app.getHttpServer()) 28 | .post('/register') 29 | .send({ 30 | username: 'hantsy', 31 | password: 'password', 32 | email: 'hantsy@test.com', 33 | firstName: 'Hantsy', 34 | lastName: 'Bai' 35 | }); 36 | expect(res.status).toBe(409); 37 | }); 38 | 39 | it('if email is existed', async () => { 40 | const res = await request(app.getHttpServer()) 41 | .post('/register') 42 | .send({ 43 | username: 'hantsy1', 44 | password: 'password', 45 | email: 'hantsy@example.com', 46 | firstName: 'Hantsy', 47 | lastName: 'Bai' 48 | }); 49 | expect(res.status).toBe(409); 50 | }); 51 | 52 | it('successed', async () => { 53 | const res = await request(app.getHttpServer()) 54 | .post('/register') 55 | .send({ 56 | username: 'hantsy1', 57 | password: 'password', 58 | email: 'hantsy@gmail.com', 59 | firstName: 'Hantsy', 60 | lastName: 'Bai' 61 | }); 62 | expect(res.status).toBe(201); 63 | }); 64 | }); 65 | 66 | describe('if user is not logged in', () => { 67 | it('/posts (GET)', async () => { 68 | const res = await request(app.getHttpServer()).get('/posts').send(); 69 | expect(res.status).toBe(200); 70 | expect(res.body.length).toEqual(3); 71 | }); 72 | 73 | it('/posts (GET) if none existing should return 404', async () => { 74 | const id = new mongoose.Types.ObjectId(); 75 | const res = await request(app.getHttpServer()).get('/posts/' + id); 76 | expect(res.status).toBe(404); 77 | }); 78 | 79 | it('/posts (GET) if invalid id should return 400', async () => { 80 | const id = "invalidid"; 81 | const res = await request(app.getHttpServer()).get('/posts/' + id); 82 | expect(res.status).toBe(400); 83 | }); 84 | 85 | it('/posts (POST) should return 401', async () => { 86 | const res = await request(app.getHttpServer()) 87 | .post('/posts') 88 | .send({ title: 'test title', content: 'test content' }); 89 | expect(res.status).toBe(401); 90 | }); 91 | 92 | it('/posts (PUT) should return 401', async () => { 93 | const id = new mongoose.Types.ObjectId(); 94 | const res = await request(app.getHttpServer()) 95 | .put('/posts/' + id) 96 | .send({ title: 'test title', content: 'test content' }); 97 | expect(res.status).toBe(401); 98 | }); 99 | 100 | it('/posts (DELETE) should return 401', async () => { 101 | const id = new mongoose.Types.ObjectId(); 102 | const res = await request(app.getHttpServer()) 103 | .delete('/posts/' + id) 104 | .send(); 105 | expect(res.status).toBe(401); 106 | }); 107 | }); 108 | 109 | describe('if user is logged in as (USER)', () => { 110 | let jwttoken: any; 111 | beforeEach(async () => { 112 | const res = await request(app.getHttpServer()) 113 | .post('/auth/login') 114 | .send({ username: 'hantsy', password: 'password' }); 115 | 116 | expect(res.status).toBe(201); 117 | jwttoken = res.body.access_token; 118 | //console.log(JSON.stringify(res)); 119 | }); 120 | 121 | it('/posts (GET)', async () => { 122 | const res = await request(app.getHttpServer()).get('/posts'); 123 | expect(res.status).toBe(200); 124 | expect(res.body.length).toEqual(3); 125 | }); 126 | 127 | it('/posts (POST) with empty body should return 400', async () => { 128 | const res = await request(app.getHttpServer()) 129 | .post('/posts') 130 | .set('Authorization', 'Bearer ' + jwttoken) 131 | .send({}); 132 | console.log(res.status); 133 | expect(res.status).toBe(400); 134 | }); 135 | 136 | it('/posts (PUT) if none existing should return 404', async () => { 137 | const id = new mongoose.Types.ObjectId(); 138 | const res = await request(app.getHttpServer()) 139 | .put('/posts/' + id) 140 | .set('Authorization', 'Bearer ' + jwttoken) 141 | .send({ title: 'test title', content: 'test content' }); 142 | expect(res.status).toBe(404); 143 | }); 144 | 145 | it('/posts (DELETE) if none existing should return 403', async () => { 146 | const id = new mongoose.Types.ObjectId(); 147 | const res = await request(app.getHttpServer()) 148 | .delete('/posts/' + id) 149 | .set('Authorization', 'Bearer ' + jwttoken) 150 | .send(); 151 | expect(res.status).toBe(403); 152 | }); 153 | 154 | it('/posts crud flow', async () => { 155 | //create a post 156 | const res = await request(app.getHttpServer()) 157 | .post('/posts') 158 | .set('Authorization', 'Bearer ' + jwttoken) 159 | .send({ title: 'test title', content: 'test content' }); 160 | expect(res.status).toBe(201); 161 | const saveduri = res.get('Location'); 162 | //console.log(saveduri); 163 | 164 | // get the saved post 165 | const resget = await request(app.getHttpServer()).get(saveduri); 166 | expect(resget.status).toBe(200); 167 | expect(resget.body.title).toBe('test title'); 168 | expect(resget.body.content).toBe('test content'); 169 | expect(resget.body.createdAt).toBeDefined(); 170 | 171 | // update the post 172 | const updateres = await request(app.getHttpServer()) 173 | .put(saveduri) 174 | .set('Authorization', 'Bearer ' + jwttoken) 175 | .send({ title: 'updated title', content: 'updated content' }); 176 | expect(updateres.status).toBe(204); 177 | 178 | // verify the updated post 179 | const updatedres = await request(app.getHttpServer()).get(saveduri); 180 | expect(updatedres.status).toBe(200); 181 | expect(updatedres.body.title).toBe('updated title'); 182 | expect(updatedres.body.content).toBe('updated content'); 183 | expect(updatedres.body.updatedAt).toBeDefined(); 184 | 185 | // creat a comment 186 | const commentres = await request(app.getHttpServer()) 187 | .post(saveduri + '/comments') 188 | .set('Authorization', 'Bearer ' + jwttoken) 189 | .send({ content: 'test content' }); 190 | expect(commentres.status).toBe(201); 191 | expect(commentres.get('Location')).toBeTruthy(); 192 | 193 | // get the comments of post 194 | const getCommentsRes = await request(app.getHttpServer()).get( 195 | saveduri + '/comments', 196 | ); 197 | expect(getCommentsRes.status).toBe(200); 198 | expect(getCommentsRes.body.length).toEqual(1); 199 | 200 | // delete the posts 201 | const deleteRes = await request(app.getHttpServer()) 202 | .delete(saveduri) 203 | .set('Authorization', 'Bearer ' + jwttoken) 204 | .send(); 205 | expect(deleteRes.status).toBe(403); 206 | }); 207 | }); 208 | 209 | describe('if user is logged in as (ADMIN)', () => { 210 | let jwttoken: any; 211 | beforeEach(async () => { 212 | const res = await request(app.getHttpServer()) 213 | .post('/auth/login') 214 | .send({ username: 'admin', password: 'password' }); 215 | jwttoken = res.body.access_token; 216 | // console.log(jwttoken); 217 | }); 218 | 219 | it('/posts (DELETE) if none existing should return 404', async () => { 220 | const id = new mongoose.Types.ObjectId(); 221 | const res = await request(app.getHttpServer()) 222 | .delete('/posts/' + id) 223 | .set('Authorization', 'Bearer ' + jwttoken) 224 | .send(); 225 | expect(res.status).toBe(404); 226 | }); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*stub.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./src", 13 | "incremental": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------