DEV Community

Cover image for Scalable REST APIs with NestJS: A Testing-Driven Approach
Subham
Subham

Posted on

Scalable REST APIs with NestJS: A Testing-Driven Approach

Why NestJS? 🤔

NestJS, a powerful and scalable framework, is here to revolutionize the way we build APIs. It leverages TypeScript, ensuring your code is clean, easy to debug, and, most importantly, reliable.

The Power of Modularity 💪

NestJS is built around the concept of modularity. This means you can organize your code into separate modules, making it easier to understand, maintain, and scale.

Connect 👋

Repo 📚

Scalable Server Architecture

TOC

1. Basic Understanding and Setup

1.1 Let's make a module

  1. Create a Basic Module: Start by creating a new folder called auth inside the src folder. In the auth folder, create a new file called auth.module.ts.

  2. Define the Module: In auth.module.ts, define the AuthModule class and decorate it with @Module({}). This decorator tells NestJS that this class is a module.

import { Module } from '@nestjs/common';

@Module({})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode
  1. Use the Module: You can use this module in your application by adding it to the imports array of the AppModule or any other module where you want to use it.

Before:

import { Module } from '@nestjs/common';

@Module({
  imports: [],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

After:

import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [AuthModule], // Add the AuthModule to the imports array
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode
  1. Create More Modules: You can create more modules using the NestJS CLI. For example, to create a user module, you can use the following command in the terminal:
nest g module user
Enter fullscreen mode Exit fullscreen mode

This command will create a new folder called user and a new file called user.module.ts inside the user folder. It will also automatically import the UserModule into the AppModule.

  1. Create a bookmarks Module: Similarly, you can create a bookmarks module using the following command:
nest g module bookmarks
Enter fullscreen mode Exit fullscreen mode
  1. Final Structure: After creating the auth, user, and bookmark modules, your app.module.ts file should look like this:
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { BookmarkModule } from './bookmark/bookmark.module';

@Module({
  imports: [AuthModule, UserModule, BookmarkModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

This structure allows you to organize your application into distinct modules, each responsible for a specific feature. This modular approach makes your application easier to understand, develop, and test.

1.2 Let's make a basic controller and service

  1. Create the Controller and Service files: In the auth folder, create two files: auth.controller.ts and auth.service.ts.

  2. Define the Controller: In auth.controller.ts, define the AuthController class and decorate it with @Controller('auth'). This decorator tells NestJS that this class is a controller and maps any incoming requests with the path /auth to this controller.

import { Controller } from '@nestjs/common';

@Controller('auth')
export class AuthController {}
Enter fullscreen mode Exit fullscreen mode
  1. Define the Service: In auth.service.ts, define the AuthService class and decorate it with @Injectable(). This decorator will allow NestJS to manage this class as a provider that can be injected into other classes.
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthService {}
Enter fullscreen mode Exit fullscreen mode
  1. Register the Controller and Service in the Module: In the AuthModule, import AuthController and AuthService and add them to the controllers and providers arrays respectively. This will allow NestJS to recognize them as part of the module.
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';

@Module({
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode
  1. Inject the Service into the Controller: In AuthController, inject an instance of AuthService through the constructor. This allows you to use the service's methods in the controller.
import { Controller } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}
}
Enter fullscreen mode Exit fullscreen mode

Note: The above code is a shorthand for manually creating a new instance of AuthService in the constructor.

  1. Use the Service's methods in the Controller: If you have a method in AuthService, you can use it in AuthController. For example, if AuthService has a test() method:
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthService {
  test() {}
}
Enter fullscreen mode Exit fullscreen mode

You can call this method in AuthController like this:

import { Controller } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {
    this.authService.test();
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a basic pattern in NestJS for separating the handling of HTTP requests (in the controller) from the business logic (in the service). The controller's job is to receive the request and send the response, while the service performs the business logic.

2. Setting Up the Auth Controller

2.1 Creating Basic Endpoints

  1. Create Basic Endpoints: Login and Signup are two common endpoints for authentication. Let's create these basic endpoints in the AuthController.
import { Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup() {
    return 'I am a signup';
  }

  @Post('login')
  login() {
    return 'I am a login';
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, if you make a POST request to /auth/signup or /auth/login, you will receive the corresponding response.

  1. Data Types and JSON: With NestJS, you don't have to worry about the data type you send or receive. If you return an object, it will be automatically converted to JSON. If you send a JSON object, it will be automatically parsed. For example:
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup() {
    return { message: 'I am a signup' }; // This will be converted to JSON
  }

  @Post('login')
  login() {
    return { message: 'I am a login' }; // This will be converted to JSON
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Using the AuthService: You can use the AuthService to handle the logic for the signup and login methods. This keeps your controller and service clean and readable.

In auth.service.ts:

import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthService {
  signup() {
    return 'I am a signup';
  }

  login() {
    return 'I am a login';
  }
}
Enter fullscreen mode Exit fullscreen mode

In auth.controller.ts:

import { Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup() {
    return this.authService.signup();
  }

  @Post('login')
  login() {
    return this.authService.login();
  }
}
Enter fullscreen mode Exit fullscreen mode

In this setup, the AuthController handles the HTTP requests and responses, while the AuthService contains the business logic for signup and login. This separation of concerns makes your code more organized and maintainable.

3. Setting up DB with docker(Prisma)

  • Create a docker-compose.yml file in the root of your project with the following content:
version: '3.8'
services:
  dev-db:
    image: postgres:13
    ports:
      - 5434:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: 123
      POSTGRES_DB: nest
Enter fullscreen mode Exit fullscreen mode
  • Then run docker compose up dev-db -d to start the database.
  • Then docker ps to check container id
  • Then docker logs <container_id> to check logs of the container
2024-02-18 19:22:56.603 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2024-02-18 19:22:56.607 UTC [27] LOG:  database system was shut down at 2024-02-18 19:22:52 UTC
2024-02-18 19:22:56.614 UTC [1] LOG:  database system is ready to accept connections
Enter fullscreen mode Exit fullscreen mode

4. Setting up TypeORM with NestJS (Prisma)

  • Install the Prisma CLI globally by running the following command in your terminal:
npm install -g prisma
Enter fullscreen mode Exit fullscreen mode
  • Also install the Prisma client as a development dependency in your NestJS project:
npm install @prisma/client
Enter fullscreen mode Exit fullscreen mode
  • Now run the following command to initialize Prisma in your project:
npx prisma init
Enter fullscreen mode Exit fullscreen mode
  • It will create postgres by default, you can change it to mysql or sqlite

  • Open prisma in your project now paste the following on schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now())
  email     String
  hash      String
  firstName String?
  lastName  String?
}

model Bookmarks {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  updatedAt   DateTime @default(now())
  title       String
  Description String
  link        String
}

Enter fullscreen mode Exit fullscreen mode
  • Now modify the .env file's DATABASE_URL

default

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
Enter fullscreen mode Exit fullscreen mode

change to

DATABASE_URL="postgresql://postgres:123@localhost:5434/nest?schema=public"
Enter fullscreen mode Exit fullscreen mode
  • Now migrate the database by running the following command:
npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode
  • You can use prisma studio to check the database by running the following command:
npx prisma studio
Enter fullscreen mode Exit fullscreen mode
  • In your bash run the following command:
nest g module prisma
Enter fullscreen mode Exit fullscreen mode
  • Now create a prisma service by running the following command:
nest g service prisma --no-spec
Enter fullscreen mode Exit fullscreen mode
  • Now modify the prisma.service.ts file to look like this:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient {
  constructor() {
    super({
      datasources: {
        db: {
          url: 'postgresql://postgres:123@localhost:5434/nest?schema=public',
        },
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Now modify the prisma.module.ts file to look like this:

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global() //It will make the service available to all modules
@Module({
  providers: [PrismaService],
  exports: [PrismaService],//you need to export the service to use it in other modules
})
export class PrismaModule {}
Enter fullscreen mode Exit fullscreen mode

5. Understanding DTOs and Class-Validator in NestJS

Data Transfer Objects (DTOs) are used to structure and validate the data that is sent over the network. They ensure that the data adheres to a specific format and meets certain validation criteria. In NestJS, we often use DTOs with the class-validator package for this purpose.

5.1 Installing Class-Validator

First, install the class-validator and class-transformer packages using npm:

npm i class-validator class-transformer
Enter fullscreen mode Exit fullscreen mode

5.2 Creating and Validating DTOs

Create a dto folder inside the auth folder. Inside the dto folder, create a file named auth.dto.ts. This file will define the data structure and validation rules for the authentication data.

import { IsEmail, IsNotEmpty, IsString } from 'class-validator';

export class AuthDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsString()
  @IsNotEmpty()
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

In the above code, @IsEmail(), @IsNotEmpty(), and @IsString() are decorators provided by class-validator. They specify that the email should be a valid email address, and both the email and password should be non-empty strings.

5.3 Using DTOs in Controllers

You can now use this DTO in your controller. The @Body() decorator in combination with the DTO type will automatically validate the incoming request data and throw an error if the data is invalid.

import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from './dto';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup(@Body() dto: AuthDto) {
    return this.authService.signup(dto);
  }

  @Post('login')
  login() {
    return this.authService.login();
  }
}
Enter fullscreen mode Exit fullscreen mode

5.4 Global Validation Pipe

To enable automatic validation globally, you can add a global pipe in your main.ts file:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
  }));
  await app.listen(3333);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

The whitelist: true option will strip any properties that don't have any decorators in the DTO.

This way, you can ensure that all incoming request data is validated according to the rules defined in your DTOs.
This helps to maintain data integrity and security in your application.

6. Implementing Signup Logic with Argon2 and Prisma

In this section, we will create a signup logic using NestJS, Prisma, and argon2 for password hashing.

6.1 Installing Argon2

Argon2 is a password hashing algorithm that is considered to be very secure. It's a better choice than bcrypt. Install it using npm:

npm i argon2
Enter fullscreen mode Exit fullscreen mode

6.2 Basic Signup Logic

Here is the basic signup logic:

  • Get the email and password from the request body
  • Hash the password using an algorithm
  • Create a new user in the database with the email and hashed password
  • Return the user data
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
import * as argon from 'argon2';

@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService) {}

  async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password); //hashing the password
    const user = await this.prisma.user.create({ //creating the user
      data: { //data to be created
        email: dto.email, //email field from the dto
        hash, //hashed password
      },
    });
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

This will return the following response:

{
    "id": 4,
    "createdAt": "2024-02-28T04:38:40.804Z",
    "updatedAt": "2024-02-28T04:38:40.804Z",
    "email": "subham@gmail.com",
    "hash": "$argon2id$......",
    "firstName": null,
    "lastName": null
}
Enter fullscreen mode Exit fullscreen mode

However, we don't want the hash to be returned to the user. We can modify the signup function to select only the necessary fields:

async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password); //hashing the password
    const user = await this.prisma.user.create({ //creating the user
      data: { //data to be created
        email: dto.email, //email field from the dto
        hash, //hashed password
      },
        select: { //selecting the fields to be returned
          id: true,
          email: true,
          firstName: true,
          lastName: true,
        },
    });
    return user;
  }
Enter fullscreen mode Exit fullscreen mode

Or, you can directly delete the hash:

async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password);
    const user = await this.prisma.user.create({
        data: {
            email: dto.email,
            hash,
        },
    });
    delete user.hash; //directly delete
    return user;
}
Enter fullscreen mode Exit fullscreen mode

6.3 Handling Unique Email Validation

If we sign up multiple times with the same email, it will create multiple users with the same email. We need to check if the user already exists or not.

First, modify the schema.prisma file to make the email field unique:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int         @id @default(autoincrement())
  createdAt DateTime    @default(now())
  updatedAt DateTime    @default(now())
  email     String      @unique//making the email field unique
  hash      String
  firstName String?
  lastName  String?
  Bookmarks Bookmarks[]

  @@map("users") //mapping the table name
}

model Bookmarks {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  updatedAt   DateTime @default(now())
  title       String
  Description String
  link        String
  userId      Int
  user        User     @relation(fields: [userId], references: [id]) //relation with the user table

  @@map("bookmarks")//mapping the table name
}
Enter fullscreen mode Exit fullscreen mode

Now run npx prisma migrate dev to migrate the database and enter a name for the migration ex: make email unique. Now run npx prisma studio to check the database, and you will see the email field is unique now.

6.4 Handling Errors

If you try to sign up with an email that already exists, you will get an error. This is because we are not handling the error, so let's handle the error:

 async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password);
    try {
      const user = await this.prisma.user.create({
        data: {
          email: dto.email,
          hash,
        },
      });
      delete user.hash;
      return user;
    } catch (error) {
      if (error instanceof PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new ForbiddenException('Credentials taken');
        }
      }
      throw error;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Now you can see the error message in the postman:

{
    "statusCode": 403,
    "message": "Credentials taken",
    "error": "Forbidden"
}
Enter fullscreen mode Exit fullscreen mode

6.5 Creating a Custom Exception Filter

If you want to make it more production-ready, you can create a custom exception filter to handle the error. Make a higher-order function to handle the error:

//async-error-handler.ts

import { ForbiddenException } from '@nestjs/common';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';

export const asyncErrorHandler = (asyncFun: (...args: any[]) => Promise<any>) => {
    return async (...args: any[]) => {
        try {
            return await asyncFun(...args);
        } catch (error) {
            if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {
                throw new ForbiddenException('Credentials taken');
            }
            throw error;
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

Now you can use this and without try-catch block:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
import * as argon from 'argon2';
import { asyncErrorHandler } from '../errors/async-error-handler';

@Injectable()
export class AuthService {
  signup = asyncErrorHandler(async (dto: AuthDto) => {
    const hash = await argon.hash(dto.password);
    const user = await this.prisma.user.create({
      data: {
        email: dto.email,
        hash,
      },
    });
    delete user.hash;
    return user;
  });

  constructor(private prisma: PrismaService) {}

  login() {
    return 'I am a login';
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Implementing Login Logic

In this section, we will create a login logic using NestJS, Prisma, and argon2 for password verification. The basic login logic involves the following steps:

  1. Find the user by email.
  2. If a user does not exist, throw an exception.
  3. Compare the password.
  4. If the password does not match, throw an exception.
  5. Return the user.

Here is the corresponding code:

signin = asyncErrorHandler(async (dto: AuthDto) => {
    const user = await this.prisma.user.findUnique({
        where: {
            email: dto.email,
        },
    });
    if (!user) {
        throw new NotFoundException('User not found');
    }
    const match = await argon.verify(user.hash, dto.password);
    if (!match) {
        throw new UnauthorizedException('Invalid credentials');
    }
    delete user.hash;
    return user;
});
Enter fullscreen mode Exit fullscreen mode

8. Automating Postgres Restart & Prisma Migrations

In this section, we will automate the process of restarting Postgres and running Prisma migrations. Follow these steps:

  1. Delete the package-lock.json file and run yarn to install the dependencies.
  2. Modify the script and add the following lines to the package.json:
"prisma:dev:deploy": "prisma migrate deploy",
"db:dev:rm": "docker compose rm dev-db -s -f -v",
"db:dev:up": "docker compose up dev-db -d",
"db:dev:restart": "yarn db:dev:rm && yarn db:dev:up && node -e \"setTimeout(() => console.log('Done waiting'), 1000)\" && yarn prisma:dev:deploy",
Enter fullscreen mode Exit fullscreen mode

The prisma:dev:deploy command will migrate the database to our dev database. The docker compose rm dev-db -s -f -v command will remove, stop, and remove the volume of the container. The docker compose up dev-db -d command will start the container in the background. The db:dev:restart command will remove the container, start the container, wait for 1 second, and then migrate the database to our dev database.

  1. Run yarn db:dev:restart to restart the database and migrate the database to our dev database.
  2. Run npx prisma studio to check the database.
  3. Run yarn start:dev to start the server.

9. NestJs config module

9.1 Basic Config Setup

  • Install the config module using npm:
yarn add @nestjs/config
Enter fullscreen mode Exit fullscreen mode
  • import in app.module.ts
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,//this is same as @Global() decorator
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode
  • Now open the prisma.service.ts file and modify it to look like this:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient {
    constructor() {
        super({
            datasources: {
                db: {
                    url: 'postgresql://postgres:123@localhost:5434/nest?schema=public',
                },
            },
        });
    }
}


Enter fullscreen mode Exit fullscreen mode

To simply change

import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@nestjs/config';

@Injectable() //inject the config service in the constructor
export class PrismaService extends PrismaClient {
    constructor(config: ConfigService) {//inject the config service in the constructor
        super({
            datasources: {
                db: {
                    url: config.get('DATABASE_URL'),//getting the database url from the .env file
                },
            },
        });
    }
}

Enter fullscreen mode Exit fullscreen mode
  • make sure in you .env file you have the following:
DATABASE_URL="postgresql://postgres:123@localhost:5434/nest?schema=public"
Enter fullscreen mode Exit fullscreen mode
  • Now run yarn start:dev to start the server.

9.2 Production Config Setup

  • Let's try to make it production ready so add a config folder in the root of your project and add a config.ts file in it.
  1. Update your config.ts file: Your config.ts file should return an object with the configuration for your application. Here's an example:
// config.ts
export default () => ({
  database: {
    url: process.env.DATABASE_URL,
  },
});
Enter fullscreen mode Exit fullscreen mode

In this example, config.ts exports a function that returns an object with your database configuration. The DATABASE_URL is read from the environment variables.

  1. Update your app.module.ts file: In your app.module.ts file, import ConfigModule from @nestjs/config, import your configuration from config.ts, and add ConfigModule to the imports array in the module metadata. Here's how you can do it:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
    }),
    // other modules...
  ],
  // controllers, providers, etc...
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

In the above code, load: [configuration] tells ConfigModule to load the configuration from the configuration function.

10. Passport & JWT module setup

10.1 Basic Setup of Passport & JWT

  • Install the passport and jwt module using npm:
yarn add @nestjs/passport passport @nestjs/jwt passport-jwt

yarn add -D @types/passport-jwt
Enter fullscreen mode Exit fullscreen mode
  • Now open auth.module.ts and modify it to look like this:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [JwtModule.register({})],//import the jwt module
  //controllers, providers, etc...
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

Purpose is to sing the token and verify the token

  • Now open auth.service.ts and modify it to look like this:
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
//other...
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  signup = asyncErrorHandler(async (dto: AuthDto) => {
   //previous code..
  });
  signin = asyncErrorHandler(async (dto: AuthDto) => {
    //previous code..
  });

  constructor(
    private prisma: PrismaService,
    private jwt: JwtService,//dependency injection
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

so we have injected the jwt service in the constructor, so now we can use it in the service

  • We need to return the token when user signs in, and send the token when user signs up
  • let's make a function which will take the payload and options and return the token
  signToken = asyncErrorHandler(async (userId: number, email: string) => {
    const payload = {
        sub: userId,//sub is the subject of the token 
        email,//optional
    };
    const secret = this.config.get('JWT_SECRET');
    //token will expire in 15 minutes
    //payload is the data you want to store in the token
    const token = this.jwt.sign(payload, {
        expiresIn: '15m',
        secret: secret,
    });
    return { access_token: token };//return the token
});
Enter fullscreen mode Exit fullscreen mode
  • this.config.get('JWT_SECRET'); import the config service and get the JWT_SECRET from the .env file
  • and call this function in the signin function and signup function
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
import * as argon from 'argon2';
import { asyncErrorHandler } from '../errors/async-error-handler';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
    signToken = asyncErrorHandler(async (userId: number, email: string) => {
        const payload = {
            sub: userId,
            email,
        };
        const secret = this.config.get('JWT_SECRET');
        const token = this.jwt.sign(payload, {
            expiresIn: '15m',
            secret: secret,
        });
        return { access_token: token };
    });
    signup = asyncErrorHandler(async (dto: AuthDto) => {
        const hash = await argon.hash(dto.password);
        const user = await this.prisma.user.create({
            data: {
                email: dto.email,
                hash,
            },
        });
        return this.signToken(user.id, user.email); //return the token
    });
    signin = asyncErrorHandler(async (dto: AuthDto) => {
        const user = await this.prisma.user.findUnique({
            where: {
                email: dto.email,
            },
        });
        if (!user) {
            throw new NotFoundException('User not found');
        }
        const match = await argon.verify(user.hash, dto.password);
        if (!match) {
            throw new UnauthorizedException('Invalid credentials');
        }
        return this.signToken(user.id, user.email); //return the token
    });

    constructor(
        private prisma: PrismaService,
        private jwt: JwtService,
        private config: ConfigService,//call the ConfigService in the constructor
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

10.2 Let's setup strategy

  • We need to validate the token when a user sends the token in the header, and we need to extract the token from the header and validate it
  • So create a folder called strategies in the auth folder and create a file called jwt.strategy.ts in it and index.ts file in the strategies folder

  • You can copy this code from here:

jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()//injectable means it can be injected in other classes as a dependency
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { //extend means it is a subclass of the Strategy class
  constructor(//constructor means the strategy is dependent on the prisma service and the config service
    config: ConfigService,
    private prisma: PrismaService, //private means it is a private property of the class
  ) {
    super({//super means it is calling the constructor of the parent class which is the Strategy class
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //it says to extract the token from the header
      secretOrKey: config.get('JWT_SECRET'), //get the secret from the .env file
    });
  }

    validate(payload: any) { //validate the token and return the payload
        return payload;
    }
}

Enter fullscreen mode Exit fullscreen mode

index.ts

export * from './jwt.strategy';
Enter fullscreen mode Exit fullscreen mode

It also a provider, so we need to add it to the provider array in the auth.module.ts file

providers: [AuthService, JwtStrategy],//add the JwtStrategy to the providers array
Enter fullscreen mode Exit fullscreen mode

11. NestGuard

11.1 Protecting Routes with Guards

  • Now we need to protect the routes with the guards, make a user module and a user controller and a user service and a user entity user module
@Module({
    controllers: [UserController]
})
Enter fullscreen mode Exit fullscreen mode

user controler

import { Controller, Get, UseGuards } from '@nestjs/common';

import { AuthGuard } from '@nestjs/passport';



@Controller('user')

export class UserController {

  @UseGuards(AuthGuard('jwt')) //use the jwt guard

  @Get('me')

  getMe() {

    return 'This is the user me';

  }

}
Enter fullscreen mode Exit fullscreen mode

11.2 Providers

auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { JwtStrategy } from './strategy';

@Module({
  imports: [JwtModule.register({})],
  controllers: [AuthController],
  providers: [AuthService, JwtService, JwtStrategy],//add the JwtStrategy to the providers array
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

11.3 Return User Payload

jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    config: ConfigService,
    private prisma: PrismaService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: config.get('JWT_SECRET'),
    });
  }

  async validate(payload: { sub: number; email: string }) { //return the payload
    const user = await this.prisma.user.findUnique({ //find the user by the id
      where: {
        id: payload.sub,
      },
    });
    delete user.hash; //delete the hash
    return user; //return the user
  }
}
Enter fullscreen mode Exit fullscreen mode

11.4 JWT Guard

Make a folder in auth name it guard and here use jwt.guard.ts and index.ts and then use it in the user.controller.ts file

jwt.guard.ts

import { AuthGuard } from '@nestjs/passport';

export class JwtGuard extends AuthGuard('jwt') {
  constructor() {
    super();
  }
}
Enter fullscreen mode Exit fullscreen mode

index.ts

export * from './jwt.guard';
Enter fullscreen mode Exit fullscreen mode

user.controller.ts



import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';

@Controller('user')
export class UserController {
    @UseGuards(AuthGuard('jwt'))
    @Get('me')
    getMe(@Req() req: Request) {
        return req.user;
    }
}


// Replace with .....

import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtGuard } from '../auth/guard';

@Controller('user')
export class UserController {
  @UseGuards(JwtGuard) //use the jwt guard
  @Get('me')
  getMe(@Req() req: Request) {
    return req.user;
  }
}
Enter fullscreen mode Exit fullscreen mode

12. Custom Param Decorator

12.1 Creating a GetUser Decorator

  • the purpose is to get the user from the request object and use it in the controller

Decorators are a powerful feature in TypeScript and JavaScript that allow you to add metadata to classes, methods, and properties. In NestJS, decorators are used to define routes, inject dependencies, and more. You can also create custom decorators to add your own functionality to your application.

First make a folder called decorators in the auth folder and then make a file called get-user.decorator.ts and index.ts in the decorators folder

get-user.decorator.ts

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
    const request: Express.Request = ctx.switchToHttp().getRequest();
    return request.user;
});

Enter fullscreen mode Exit fullscreen mode

index.ts

export * from './get-user.decorator';
Enter fullscreen mode Exit fullscreen mode

user.controller.ts


import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtGuard } from '../auth/guard';

@Controller('user')
export class UserController {
    @UseGuards(JwtGuard)
    @Get('me')
    getMe(@Req() req: Request) {
        return req.user;
    }
}


// Replace with .....

import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { GetUser } from '../auth/decorator';
import { User } from '@prisma/client';

@UseGuards(JwtGuard) //place it here so that our entire controller is protected
@Controller('user')
export class UserController {
  @Get('me')
  getMe(@GetUser() user: User) { //use the GetUser decorator
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode
  • let's return the user id instead of the entire user object > Modify the get-user.decorator.ts file to look like this:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetUser = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
  const request: Express.Request = ctx.switchToHttp().getRequest();
  if (data) { //if data is provided then return the user[data]
    return request.user[data];
  }
  return request.user;
});
Enter fullscreen mode Exit fullscreen mode

If you provide a string, it will return the user[data] else it will return the entire user object

  • Now let's say you want the email of the user, then you can use the GetUser decorator like this:
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { GetUser } from '../auth/decorator';
import { User } from '@prisma/client';

@UseGuards(JwtGuard)
@Controller('user')
export class UserController {
  @Get('me')
  getMe(@GetUser() user: User, @GetUser('email') email: string) { //use the GetUser decorator with the email
    console.log({
      email,
    });
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

12.2 Http Decorator

  • You can pass you status code which you want to return in the response

auth.controller.ts

import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from './dto';

@Controller('auth')
export class AuthController {
    constructor(private authService: AuthService) {}

    @HttpCode(201) //use the @HttpCode decorator
    @Post('signup')
    signup(@Body() dto: AuthDto) {
        return this.authService.signup(dto);
    }

    @Post('signin')
    signin(@Body() dto: AuthDto) {
        return this.authService.signin(dto);
    }
}

// Or you can use it like this

@HttpCode(HttpStatus.OK) //use the @HttpCode decorator

Enter fullscreen mode Exit fullscreen mode

13. E2E Testing

13.1 Setting up Pactum JS

https://pactumjs.github.io/

  • Install the pactum js using yarn:
yarn add -D pactum
Enter fullscreen mode Exit fullscreen mode
  • Open the test folder and clear the app.e2e-spec.ts file and modify it to look like this:

  • Open your script in the package.json file and add the following:

"test:e2e": "jest --watch --no-cache --config ./test/jest-e2e.json"
Enter fullscreen mode Exit fullscreen mode

This script allows you to run your end-to-end tests using Jest. The --watch flag means Jest will watch your files for changes and rerun tests when necessary. The --no-cache flag disables caching, ensuring that your tests always run with the latest changes. The --config flag specifies the configuration file for Jest.

// Import necessary modules
import { AppModule } from '../src/app.module';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';

// Declare a test suite named 'App e2e'
describe('App e2e', () => {
    // Declare a variable to hold our app instance
    let app: INestApplication;

    // Before all tests, create and initialize the app
    beforeAll(async () => {
        // Create a module for testing
        const moduleRef = await Test.createTestingModule({
            imports: [AppModule],
        }).compile();//compile the module 

        // Create an instance of the app
        app = moduleRef.createNestApplication();

        // Apply a global validation pipe
        app.useGlobalPipes(
            new ValidationPipe({
                whitelist: true,
            }),
        );

        // Initialize the app
        await app.init();
    });

    // After all tests, close the app
    afterAll(() => {
        app.close();
    });

    // Placeholder for a test case
    it.todo('should pass');
});

Enter fullscreen mode Exit fullscreen mode

14. Setting Up Test Database

  • Open the docker-compose.yml file and modify it to look like this:

Just replicate the dev-db and change the port

version: '3.8'
services:
  dev-db:
    image: postgres:13
    ports:
      - 5434:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: 123
      POSTGRES_DB: nest
  test-db:
    image: postgres:13
    ports:
      - 5435:5432 #this should be different from the dev-db
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: 123
      POSTGRES_DB: nest
Enter fullscreen mode Exit fullscreen mode
  • Similarly modify the script for test database in the package.json file

Replicate the script

"prisma:dev:deploy": "prisma migrate deploy",
"db:dev:rm": "docker compose rm dev-db -s -f -v",
"db:dev:up": "docker compose up dev-db -d",
"db:dev:restart": "yarn db:dev:rm && yarn db:dev:up && node -e \"setTimeout(() => console.log('Done waiting'), 1000)\" && yarn prisma:dev:deploy",
"prisma:test:deploy": "dotenv -e .env.test -- prisma migrate deploy",
"db:test:rm": "docker compose rm test-db -s -f -v",
"db:test:up": "docker compose up test-db -d",
"db:test:restart": "yarn db:test:rm && yarn db:test:up && node -e \"setTimeout(() => console.log('Done waiting'), 1000)\" && yarn prisma:test:deploy",
Enter fullscreen mode Exit fullscreen mode

15. Dotenv for Development and Testing

  • Installation
yarn add -D dotenv-cli
Enter fullscreen mode Exit fullscreen mode
  • Make a file name .env.test and add the following:

  • Now modify the package.json file to look like this:

"prisma:test:deploy": "prisma migrate deploy",

[//]: # (change it to something like this)

"prisma:test:deploy": "dotenv -e .env.test -- prisma migrate deploy",
Enter fullscreen mode Exit fullscreen mode

just add dotenv -e .env.test -- before the prisma migrate deploy
It's mean that it will use the .env.test file for the test database and the .env file for the development database

Now modify the .env.test file just copy theDATABASE_URL from the .env file and change the port(which is declared in the docker-compose.yml file)

DATABASE_URL="postgresql://postgres:123@localhost:5435/nest?schema=public"
Enter fullscreen mode Exit fullscreen mode
  • Now we add this
"prebuild": "rimraf dist",
Enter fullscreen mode Exit fullscreen mode

It will remove the dist folder before building the project

  • Now change the test:e2e script in the package.json file to look like this:
"test:e2e": "jest --watch --no-cache --config ./test/jest-e2e.json"

[//]: # (change it to something like this)

"pretest:e2e": "yarn db:test:restart",
"test:e2e": "dotenv -e .env.test -- jest --watch --no-cache --config ./test/jest-e2e.json"

Enter fullscreen mode Exit fullscreen mode
  • pretest:e2e": "yarn db:test:restart, will restart the test database before running the test
  • Just add dotenv -e .env.test -- before the jest command so that it will use the .env.test file for the test database
  • It means that it will use the .env.test file for the test database and the .env file for the development database
  • For checking the migration in the test database, run the following command:
yarn test:e2e  

docker ps   //two container should be running

//for check the prisma studio for the test database

npx dotenv -e .env.test prisma studio


//for check the prisma studio for the dev database

npx prisma studio 

Enter fullscreen mode Exit fullscreen mode

16. Database Tear Down

16.1 Cleaning the Database

We need to clean the database after the test, but we don't want to restart docker-compose every time, because it takes time to restart the database

  • Open prisma.service.ts and modify it to look like this:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class PrismaService extends PrismaClient {
  constructor(config: ConfigService) {
    super({
      datasources: {
        db: {
          url: config.get('DATABASE_URL'),
        },
      },
    });
  }

  cleanDb() { //add the cleanDb function
    return this.$transaction([this.bookmark.deleteMany(), this.user.deleteMany()]);
  }
}
Enter fullscreen mode Exit fullscreen mode

The cleanDb function is used to clean up the database. It's often used in testing (like before running end-to-end tests) to ensure a consistent starting state for each test run. Here's what it does:

  1. Deletes all bookmark: this.bookmark.deleteMany() deletes all records from the bookmarks table. This is done first because the bookmarks table likely has a foreign key constraint on the user table. If we tried to delete the users first, the database would throw an error because there would still be bookmarks in the database referencing those users.

  2. Deletes all users: this.user.deleteMany() deletes all records from the user table. This is done after deleting all bookmarks to avoid violating any foreign key constraints.

Both deletion operations are wrapped in a transaction (this.$transaction([])) to ensure atomicity. This means if one operation fails, the other won't be executed, leaving your database in a consistent state.

In summary, cleanDb is a utility function that helps maintain a clean, predictable state in your database, which is especially useful when running tests. It first deletes bookmarks and then users to respect the foreign key relationship from bookmarks to users.

16.2 Database Tear Down in E2E Tests

  • Open the app.e2e-spec.ts file and modify it to look like this:
import { AppModule } from '../src/app.module';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { PrismaService } from '../src/prisma/prisma.service';

describe('App e2e', () => {
  let app: INestApplication;
  let prisma: PrismaService;//declare the prisma service
  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
      }),
    );
    await app.init();
    prisma = app.get(PrismaService); //get the prisma service from the app
    await prisma.cleanDb();//clean the database before running the test
  });

  afterAll(() => { //if you close the app the datebase will be cleaned up
    app.close();
  });

  it.todo('should pass');
});

Enter fullscreen mode Exit fullscreen mode

16.3 Create the test

  • First declare this
  describe('Auth', () => {});
  describe('User', () => {});
  describe('Bookmarks', () => {});
Enter fullscreen mode Exit fullscreen mode
  • Now define this properly

describe('Auth', () => {
    describe('Signup', () => {
        it.todo('should signup');
    });
    describe('Signin', () => {
        it.todo('should signin');
    });
});
describe('User', () => {
    describe('Get me', () => {});
    describe('Edit user', () => {});
});
describe('Bookmarks', () => {
    describe('Create bookmarks', () => {});
    describe('Get bookmarks', () => {});
    describe('Get bookmark by id', () => {});
    describe('Edit bookmark', () => {});
    describe('Delete bookmark', () => {});
});
Enter fullscreen mode Exit fullscreen mode
  • Now run the test
yarn test:e2e
Enter fullscreen mode Exit fullscreen mode

it is a function used to define a test in Jest. it.todo is a special version of it used when you want to plan a test but aren't ready to write it yet. So, it.todo('should signup') means you're planning to write a test for the signup functionality, but haven't written the test code yet. When Jest runs, it will remind you that this test still needs to be written.

17. Auth E2E Testing with Pactum

17.1 Signup test

import { AppModule } from '../src/app.module';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { PrismaService } from '../src/prisma/prisma.service';
import { AuthDto } from '../src/auth/dto';
import * as pactum from 'pactum';

describe('App e2e', () => {
  let app: INestApplication;
  let prisma: PrismaService;
  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
      }),
    );
    await app.init();
    await app.listen(3333);
    prisma = app.get(PrismaService);
    await prisma.cleanDb();
    pactum.request.setBaseUrl('http://localhost:3333');//base url set 
  });

  afterAll(() => {
    app.close();
  });

  describe('Auth', () => {
    describe('Signup', () => {
      it('should signup', () => {//remove the todo and write the test
        const dto: AuthDto = { //create a dto object
          email: 'subham@gmail.com',
          password: 'Subham@123',
        };
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201); //send the request and expect the status 201 
        // .inspect(); //if you want to see the response
      });
    });
    describe('Signin', () => {
      it.todo('should signin');
    });
  });
  describe('User', () => {
    describe('Get me', () => {});
    describe('Edit user', () => {});
  });
  describe('Bookmarks', () => {
    describe('Create bookmarks', () => {});
    describe('Get bookmarks', () => {});
    describe('Get bookmark by id', () => {});
    describe('Edit bookmark', () => {});
    describe('Delete bookmark', () => {});
  });

  it.todo('should pass');
});
Enter fullscreen mode Exit fullscreen mode

17.2 Signin test

 describe('Auth', () => {
     const dto: AuthDto = {//delare here for reusing
        email: 'subham@gmail.com',
        password: 'Subham@123',
    };
    describe('Signup', () => {
        it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => { //same as the signup
      it('should signin', () => {
        return pactum.spec().post('/auth/signin').withBody(dto).expectStatus(200);
        // .inspect();
      });
    });
Enter fullscreen mode Exit fullscreen mode

17.3 Error handling


 describe('Auth', () => {
    const dto: AuthDto = {
      email: 'subham@gmail.com',
      password: 'Subham@123',
    };
    describe('Signup', () => {
      it('should throw if email empty', () => { //add  the  expectStatus(400)
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => {
      it('should throw if no body provided', () => {//add  the  expectStatus(400)
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signin', () => {
        return pactum.spec().post('/auth/signin').withBody(dto).expectStatus(200);
        // .inspect();
      });
    });
  });
Enter fullscreen mode Exit fullscreen mode

17.4 Multiple Error handling

describe('Auth', () => {
    const dto: AuthDto = {
      email: 'subham@gmail.com',
      password: 'Subham@123',
    };

    describe('Signup', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signin', () => {
        return pactum.spec().post('/auth/signin').withBody(dto).expectStatus(200);
        // .inspect();
      });
    });
});
Enter fullscreen mode Exit fullscreen mode

17.5 Storing the token

  • We need to check use information for that we need to send bearer token in the header > .stores('userAt', 'access_token'); > - stores is used to store the token in the variable userAt and access_token is the key > - if you inspect the response you will see the key access_token just copy it

  describe('Auth', () => {
    const dto: AuthDto = {
      email: 'subham@gmail.com',
      password: 'Subham@123',
    };

    describe('Signup', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signin', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody(dto)
          .expectStatus(200)
          .stores('userAt', 'access_token');//use here
        // .inspect();
      });
    });
  });
Enter fullscreen mode Exit fullscreen mode

18. User E2E Testing with Pactum

18.1 Get User with Bearer Token

describe('User', () => {
    describe('Get me', () => {
      it('should get current user', () => {
        return pactum
          .spec()
          .get('/user/me')
          .withHeaders({//send the token in the header 
            Authorization: 'Bearer $S{userAt}',//herer userAt is the variable name and access_token is the key  
          })
          .expectStatus(200);
      });
    });
    describe('Edit user', () => {});
  });
Enter fullscreen mode Exit fullscreen mode

18.2 Edit User with Bearer Token

18.2.1 DTO for Edit User
  • First write the controller and service for the edit user
  • nest g service user --no-spec

user dto edit

dto use for the validation of the data

import { IsEmail, IsOptional, IsString } from 'class-validator';
export class EditUserDto {
  @IsEmail() //the email should be a valid email
  @IsOptional()//optional
  email?: string;

  @IsString()//the first name should be a string
  @IsOptional()//optional
  firstName?: string;

  @IsString()//the last name should be a string
  @IsOptional()//optional   
  lastName?: string;
}

Enter fullscreen mode Exit fullscreen mode
18.2.2 Service for Edit User

user service

service use for the business logic

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EditUserDto } from './dto';

@Injectable()
export class UserService {
    constructor(private prisma: PrismaService) {} //inject the prisma service

    async editUser(userId: number, dto: EditUserDto) { //It will take the user id and the dto 

        //update the user by the user id 
        const user = await this.prisma.user.update({
            where: {
                id: userId,
            },
            data: {
                ...dto,
            },
        });

        delete user.hash;//delete the hash

        return user;
    }
}
Enter fullscreen mode Exit fullscreen mode
18.2.3 Controller for Edit User

user.controller.ts

import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { GetUser } from '../auth/decorator';
import { User } from '@prisma/client';
import { EditUserDto } from './dto';
import { UserService } from './user.service';

@UseGuards(JwtGuard)
@Controller('user')
export class UserController {
    constructor(private userService: UserService) {}// UserService from the service
    @Get('me')
    getMe(@GetUser() user: User) {
        return user;
    }

    @Patch() //use the patch method 
    editUser(@GetUser('id') userId: number, @Body() dto: EditUserDto) { // use the GetUser decorator to get the user id and the dto  
        return this.userService.editUser(userId, dto); //call the editUser function from the service 
    }
}

Enter fullscreen mode Exit fullscreen mode
18.2.4 Pactum Test for Edit User
  • Now write the test for the edit user
describe('Edit user', () => {
    it('should edit user', () => {
        const dto: EditUserDto = {
            firstName: 'Subham',
            email: 'subham1@gmail.com',
            lastName: 'Maity'
        };
        return pactum
            .spec()
            .patch('/users')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .withBody(dto)
            .expectStatus(200)
            .expectBodyContains(dto.firstName)
            .expectBodyContains(dto.email);;
    });
});
Enter fullscreen mode Exit fullscreen mode

19. Bookmarks Crud with Testing E2E

Step 0: Schema Define

  • Open prisma folder and modify the schema.prisma file to look like this:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  email String @unique
  hash  String

  firstName String?
  lastName  String?

  bookmarks Bookmark[]

  @@map("users")
}
//Bookmark model
model Bookmark {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  title       String
  description String?
  link        String

  userId Int
  user   User @relation(fields: [userId], references: [id])

  @@map("bookmarks")
}

Enter fullscreen mode Exit fullscreen mode

Step 1: Installation

First, we need to generate the service and controller for bookmarks. Run the following commands:

nest g service bookmark --no-spec
nest g controller bookmark --no-spec
Enter fullscreen mode Exit fullscreen mode

Step 2: Controller Structure

Next, we will set up the structure of our controller. Here are the steps:

  1. Declare the UseGuards for authentication.
  2. The constructor should be called inside BookmarkController with private bookmarksService: BookmarksService.
  3. BookmarksService should have private prisma: PrismaService inside it.
  4. Declare all the routes in the bookmark.controller.ts file.

Here's an example of what your bookmark.controller.ts file might look like:

import { Controller, Delete, Get, Patch, Post, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { BookmarksService } from './bookmark.service';

@UseGuards(JwtGuard)
@Controller('bookmarks')
export class BookmarkController {
    constructor(private bookmarkService: BookmarkService) {}

    @Get()
    getBookmarks() {}

    @Get()
    getBookmarkById() {}

    @Post()
    createBookmark() {}

    @Patch()
    editBookmarkById() {}

    @Delete()
    deleteBookmarkById() {}
}
Enter fullscreen mode Exit fullscreen mode

Step 3: - Decorators for the Bookmarks

  • use Param decorator for those routes which need the id of the bookmark

info: we already created a get-user.decorator.ts file in the auth folder, so we can use it here

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetUser = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
  const request: Express.Request = ctx.switchToHttp().getRequest();
  if (data) {
    return request.user[data];
  }
  return request.user;
});
Enter fullscreen mode Exit fullscreen mode
export class BookmarkController {
    constructor(private bookmarkService: BookmarkService) {}
    @Get()
    getBookmarks(@GetUser('id') userId: number) { //a.
        console.log(userId);
    }
    @Get(':id') 
    getBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {//b.
        console.log(userId, bookmarkId);
    }
    @Post()
    createBookmark(@GetUser('id') userId: number, @Body() dto: CreateBookmarkDto) {//c.
        console.log(userId, dto);
    }
    @Patch(':id')
    editBookmarkById(//d.
        @GetUser('id') userId: number,
        @Param('id', ParseIntPipe) bookmarkId: number,
        @Body() dto: EditBookmarkDto,
    ) {
        console.log(userId, bookmarkId, dto);
    }
    @HttpCode(HttpStatus.NO_CONTENT)//e.
    @Delete(':id')
    deleteBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {
        console.log(userId, bookmarkId);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • a. getBookmarks(@GetUser('id') userId: number): This route fetches all bookmark for a user. The @GetUser('id') decorator retrieves the user's ID.

  • b. getBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number): This route fetches a specific bookmark by its ID for a user. The @GetUser('id') decorator retrieves the user's ID, and @Param('id', ParseIntPipe) retrieves and parses the bookmark ID from the URL.

  • c. createBookmark(@GetUser('id') userId: number, @Body() dto: CreateBookmarkDto): This route creates a new bookmark for a user. The @GetUser('id') decorator retrieves the user's ID, and @Body() retrieves the request body data with validation applied.

  • d. editBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number, @Body() dto: EditBookmarkDto): This route edits a specific bookmark by its ID for a user. The @GetUser('id') decorator retrieves the user's ID, @Param('id', ParseIntPipe) retrieves and parses the bookmark ID from the URL, and @Body() retrieves the request body data with validation applied.

  • e. deleteBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number): This route deletes a specific bookmark by its ID for a user. The @GetUser('id') decorator retrieves the user's ID, and @Param('id', ParseIntPipe) retrieves and parses the bookmark ID from the URL. The @HttpCode(HttpStatus.NO_CONTENT) decorator sets the HTTP status code to 204 (No Content) when the operation is successful.

Step 4: - DTO for Bookmarks

  • Make a folder named dto inside the bookmarks folder and then make a file called create-bookmark.dto.ts,edit-bookmark.dto.ts and index.ts inside the dto folder

create-bookmark.dto.ts

import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

export class CreateBookmarkDto {
  @IsString()
  @IsNotEmpty()
  title: string;

  @IsString()
  @IsOptional()
  description?: string;

  @IsString()
  @IsNotEmpty()
  link: string;
}
Enter fullscreen mode Exit fullscreen mode

edit-bookmark.dto.ts

import { IsOptional, IsString } from 'class-validator';

export class EditBookmarkDto {
  @IsString()
  @IsOptional()
  title?: string;

  @IsString()
  @IsOptional()
  description?: string;

  @IsString()
  @IsOptional()
  link?: string;
}
Enter fullscreen mode Exit fullscreen mode

index.ts

export * from './create-bookmark.dto';
export * from './edit-bookmark.dto';
Enter fullscreen mode Exit fullscreen mode

Step 5: - Service Structure for Bookmarks

  • Construct the BookmarksService with private prisma: PrismaService inside it.
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBookmarkDto, EditBookmarkDto } from './dto';

@Injectable()
export class BookmarkService {
    constructor(private prisma: PrismaService) {}

    getBookmarks(userId: number) {
        console.log(userId);
    }

    getBookmarkById(userId: number, bookmarkId: number) {
        console.log(userId, bookmarkId);
    }

    createBookmark(userId: number, dto: CreateBookmarkDto) {
        console.log(userId, dto);
    }

    editBookmarkById(userId: number, bookmarkId: number, dto: EditBookmarkDto) {
        console.log(userId, bookmarkId, dto);
    }

    deleteBookmarkById(userId: number, bookmarkId: number) {
        console.log(userId, bookmarkId);
    }
}


Enter fullscreen mode Exit fullscreen mode

Step 6: - Bookmarks Service in the Controller

  • Inject the BookmarksService inside the BookmarkController and call the service methods inside the routes.
import {
    Body,
    Controller,
    Delete,
    Get,
    HttpCode,
    HttpStatus,
    Param,
    ParseIntPipe,
    Patch,
    Post,
    UseGuards,
} from '@nestjs/common';

import { JwtGuard } from '../auth/guard';

import { GetUser } from '../auth/decorator';

import { CreateBookmarkDto, EditBookmarkDto } from './dto';
import { BookmarkService } from './bookmark.service';

@UseGuards(JwtGuard)
@Controller('bookmarks')
export class BookmarkController {
    constructor(private bookmarksService: BookmarkService) {}

    @Get()
    getBookmarks(@GetUser('id') userId: number) {
        return this.bookmarksService.getBookmarks(userId);
    }

    @Get(':id')
    getBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {
        return this.bookmarksService.getBookmarkById(userId, bookmarkId);
    }

    @Post()
    createBookmark(@GetUser('id') userId: number, @Body() dto: CreateBookmarkDto) {
        return this.bookmarksService.createBookmark(userId, dto);
    }

    @Patch(':id')
    editBookmarkById(
        @GetUser('id') userId: number,

        @Param('id', ParseIntPipe) bookmarkId: number,

        @Body() dto: EditBookmarkDto,
    ) {
        return this.bookmarksService.editBookmarkById(userId, bookmarkId, dto);
    }

    @HttpCode(HttpStatus.NO_CONTENT)
    @Delete(':id')
    deleteBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {
        return this.bookmarksService.deleteBookmarkById(userId, bookmarkId);
    }
}

Enter fullscreen mode Exit fullscreen mode

Step 7: - E2E Testing With Business Logic (Service)

  • For testing yarn test:e2e
  1. Get Empty Bookmarks

app.e2e-spec.ts


describe('Get empty bookmarks', () => {
    it('should get empty bookmarks', () => {
        return pactum
            .spec()
            .get('/bookmarks')
            .withHeaders({ Authorization: 'Bearer $S{userAt}' })
            .expectStatus(200)
            .expectBody([]); //body should be an array and length should be 0
    });
});
Enter fullscreen mode Exit fullscreen mode

bookmark.service.ts


@Injectable()
export class BookmarksService {
  constructor(private prisma: PrismaService) {}

  getBookmarks(userId: number) {
    return this.prisma.bookmark.findMany({
      where: {
        userId,
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Create Bookmarks

app.e2e-spec.ts

describe('Create bookmarks', () => {
    const dto: CreateBookmarkDto = {
        title: 'NestJS',
        link: 'https://nestjs.com/',
    };
    it('should create bookmark', () => {
        return pactum
            .spec()
            .post('/bookmarks')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .withBody(dto)
            .expectStatus(201)
            .stores('bookmarkId', 'id')//store the bookmark id in the variable bookmarkId 
            .expectBodyContains(dto.title)
            .expectBodyContains(dto.link)

    });
});
Enter fullscreen mode Exit fullscreen mode

bookmark.service.ts

@Injectable()
export class BookmarksService {
  constructor(private prisma: PrismaService) {}

    createBookmark(userId: number, dto: CreateBookmarkDto) {
        return this.prisma.bookmark.create({
            data: {
                userId,
                ...dto,
            },
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Get Bookmarks

First it expects nothing in the get request, and then it creates a bookmark, and then it expects the length of the array to be 1

app.e2e-spec.ts

   describe('Get bookmarks', () => {
    it('should get bookmarks', () => {
        return pactum
            .spec()
            .get('/bookmarks')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(200)
            .expectJsonLength(1);//body should be an array and length should be 1
    });
});
Enter fullscreen mode Exit fullscreen mode
  1. Get Bookmark by ID

.stores('bookmarkId', 'id') when we create the bookmark we store the id in the variable bookmarkId, and then we use it here
app.e2e-spec.ts


describe('Get bookmark by id', () => {
    it('should get bookmark by id', () => {
        return pactum
            .spec()
            .get('/bookmarks/{id}')
            .withPathParams('id', '$S{bookmarkId}')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(200)
            .expectBodyContains('$S{bookmarkId}');//expect the body to contain the bookmark id 
    });
});
Enter fullscreen mode Exit fullscreen mode

bookmark.service.ts

@Injectable()
export class BookmarksService {
    constructor(private prisma: PrismaService) {}

    getBookmarkById(userId: number, bookmarkId: number) {
        return this.prisma.bookmark.findFirst({
            where: {
                id: bookmarkId,
                userId,
            },
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Edit Bookmark by ID

app.e2e-spec.ts

 describe('Edit bookmark by id', () => {
    const dto: EditBookmarkDto = {
        title: 'Kubernetes Course - Full Beginners Tutorial (Containerize Your Apps!)',
        description:
            'Learn how to use Kubernetes in this complete course. Kubernetes makes it possible to containerize applications and simplifies app deployment to production.',
    };
    it('should edit bookmark', () => {
        return pactum
            .spec()
            .patch('/bookmarks/{id}')
            .withPathParams('id', '$S{bookmarkId}')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .withBody(dto)
            .expectStatus(200)
            .expectBodyContains(dto.title)
            .expectBodyContains(dto.description);
    });
});
Enter fullscreen mode Exit fullscreen mode

bookmark.service.ts

async editBookmarkById(userId: number, bookmarkId: number, dto: EditBookmarkDto) {
    // get the bookmark by id
    const bookmark = await this.prisma.bookmark.findUnique({
        where: {
            id: bookmarkId,
        },
    });

    // check if user owns the bookmark
    if (!bookmark || bookmark.userId !== userId)
        throw new ForbiddenException('Access to resources denied');

    return this.prisma.bookmark.update({
        where: {
            id: bookmarkId,
        },
        data: {
            ...dto,
        },
    });
}
Enter fullscreen mode Exit fullscreen mode
  1. Delete Bookmark by ID

First it expects the length of the array to be 1, then it deletes the bookmark, and then it expects the length of the array to be 0
app.e2e-spec.ts

describe('Delete bookmark by id', () => {
    it('should delete bookmark', () => {
        return pactum
            .spec()
            .delete('/bookmarks/{id}')
            .withPathParams('id', '$S{bookmarkId}')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(204);
    });

    it('should get empty bookmarks', () => {
        return pactum
            .spec()
            .get('/bookmarks')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(200)
            .expectJsonLength(0);
    });
});
Enter fullscreen mode Exit fullscreen mode

bookmark.service.ts

async deleteBookmarkById(userId: number, bookmarkId: number) {
    const bookmark = await this.prisma.bookmark.findUnique({
        where: {
            id: bookmarkId,
        },
    });

    // check if user owns the bookmark
    if (!bookmark || bookmark.userId !== userId)
        throw new ForbiddenException('Access to resources denied');

    await this.prisma.bookmark.delete({
        where: {
            id: bookmarkId,
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

It will give you this output:


bash
PS > yarn test:e2e  
yarn run v1.22.19
$ docker compose rm test-db -s -f -v
[+] Stopping 1/1
 ✔ Container lesson02-test-db-1  Stopped                                                                                                                                                                                                                       0.3s 
Going to remove lesson02-test-db-1
[+] Removing 1/0
 ✔ Container lesson02-test-db-1  Removed                                                                                                                                                                                                                       0.0s 
$ docker compose up test-db -d
[+] Running 1/1
 ✔ Container lesson02-test-db-1  Started                                                                                                                                                                                                                       0.0s 
Done waiting
$ dotenv -e .env.test -- prisma migrate deploy
Environment variables loaded from .env
Prisma schema loaded from prisma\schema.prisma
Datasource "db": PostgreSQL database "nest", schema "public" at "localhost:5435"

1 migration found in prisma/migrations

Applying migration `20240307030332_init`

The following migration(s) have been applied:

migrations/
  └─ 20240307030332_init/
    └─ migration.sql

All migrations have been successfully applied.
$ dotenv -e .env.test -- jest --watch --no-cache --config ./test/jest-e2e.json
 PASS  test/app.e2e-spec.ts (5.369 s)
  App e2e
    Auth
      Signup
        √ should throw if email empty (56 ms)
        √ should throw if password empty (5 ms)
        √ should throw if no body provided (4 ms)
        √ should signup (80 ms)
      Signin
        √ should throw if email empty (3 ms)
        √ should throw if password empty (2 ms)
        √ should throw if no body provided (2 ms)
        √ should signin (66 ms)
    User
      Get me
        √ should get current user (8 ms)
      Edit user
        √ should edit user (8 ms)
    Bookmarks
      Get empty bookmarks
        √ should get bookmarks (6 ms)
      Create bookmark
        √ should create bookmark (8 ms)
      Get bookmarks
        √ should get bookmarks (5 ms)
      Get bookmark by id
        √ should get bookmark by id (5 ms)
      Edit bookmark by id
        √ should edit bookmark (10 ms)
      Delete bookmark by id
        √ should delete bookmark (9 ms)
        √ should get empty bookmarks (5 ms)

Test Suites: 1 passed, 1 total
Enter fullscreen mode Exit fullscreen mode

Top comments (0)