Today we shall be creating the products module for our backend application, starting as usual with the products module.

Products Module

Here’s the products module file:

src/modules/products/products.module.ts view raw
import { Module } from '@nestjs/common';
import { ProductsService } from './products.service';
import { ProductsController } from './products.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from './entities/product.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Product])],
  controllers: [ProductsController],
  providers: [ProductsService],
  exports: [ProductsService],
})
export class ProductsModule {}

Products Service

Our main product logic is contained within the service, and here’s the products service file:

src/modules/products/products.service.ts view raw
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DeleteResult, Repository, UpdateResult } from 'typeorm';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { Product } from './entities/product.entity';

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productsRepository: Repository<Product>,
  ) {}

  async create(createProductDto: CreateProductDto): Promise<Product> {
    return await this.productsRepository.save(createProductDto);
  }

  async findAll(): Promise<Product[]> {
    return await this.productsRepository.find();
  }

  async findOne(id: string): Promise<Product> {
    return await this.productsRepository.findOne(id);
  }

  async update(
    id: string,
    updateProductDto: UpdateProductDto,
  ): Promise<UpdateResult> {
    return await this.productsRepository.update(id, updateProductDto);
  }

  async remove(id: string): Promise<DeleteResult> {
    return await this.productsRepository.softDelete(id);
  }

  async checkIfProductsExist(productIds: string[]): Promise<Product[]> {
    return await this.productsRepository.findByIds(productIds);
  }
}

What’s contained in the service is mainly CRUD logic, and the method names pretty much describe the actions that they perform. The create method creates a new product, findAll fetches all products from Postgres, findOne retrieves a single product using its ID, update updates a product, remove however, does something unique. Rather than deleting the product from the database, it performs what’s known as a soft delete - the product is retained in our database, but is “hidden” from view. This is performed by adding a deleted_at column to the products table. When the softDelete TypeORM method is called within the remove method, the deleted_at column is automatically populated with a timestamp. Later on, when findAll or findOne methods are used to fetch data, the affected record is automatically omitted by TypeORM in the result set.

Lastly, the checkIfProductsExist method returns an array of products based off their corresponding IDs, which are also supplied as an array. It will be used later in the orders service.

Product Entity

The products entity file has remained unchanged from the one in previous tutorials:

src/modules/products/entities/product.entity.ts view raw
import { OrderItem } from '../../orders/entities/order-item.entity';
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity('products')
export class Product {
  constructor(intialData: Partial<Product> = null) {
    if (intialData !== null) {
      Object.assign(this, intialData);
    }
  }

  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar' })
  name: string;

  @Column({ name: 'unit_price', type: 'numeric' })
  unitPrice: number;

  @Column({ type: 'text' })
  description: string;

  @Column({ type: 'varchar' })
  image: string;

  @CreateDateColumn({
    name: 'created_at',
    type: 'timestamptz',
    default: 'now()',
    readonly: true,
  })
  createdAt: string;

  @UpdateDateColumn({
    name: 'updated_at',
    type: 'timestamptz',
    default: 'now()',
  })
  updatedAt: string;

  @DeleteDateColumn({
    name: 'deleted_at',
    type: 'timestamptz',
  })
  deletedAt: string;

  @OneToMany(() => OrderItem, (orderItem) => orderItem.product)
  orderItems: OrderItem[];
}

Product DTOs

Here are the product DTO files.

Create Product DTO:

src/modules/products/dto/create-product.dto.ts view raw
import { PartialType } from '@nestjs/mapped-types';
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { Product } from '../entities/product.entity';

export class CreateProductDto extends PartialType(Product) {
  @IsString()
  @IsNotEmpty()
  name?: string;

  @IsNumber()
  @IsNotEmpty()
  unitPrice?: number;

  @IsString()
  @IsNotEmpty()
  description?: string;
}

Update Product DTO:

src/modules/products/dto/update-product.dto.ts view raw
import { PartialType } from '@nestjs/mapped-types';
import { CreateProductDto } from './create-product.dto';

export class UpdateProductDto extends PartialType(CreateProductDto) {}

When creating a new product, we will be validating that both the product name and descriptions exist and are valid strings. We also validate that the product’s unit price exists and is a valid number.

Product Controller

Here is the products controller. We are using the JwtAuthGuard that we created in the previous tutorial to limit creating, updating and deleting products to authenticated customers. This is not the best solution, as ideally those functions should be performed by Administrators only, not regular customers. In future, we shall further enhance our backend via RBAC (Role Based Access Control).

src/modules/products/products.controller.ts view raw
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  UseGuards,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Product } from './entities/product.entity';
import { DeleteResult, UpdateResult } from 'typeorm';

@Controller('products')
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  @UseGuards(JwtAuthGuard)
  @Post()
  create(@Body() createProductDto: CreateProductDto): Promise<Product> {
    return this.productsService.create(createProductDto);
  }

  @Get()
  findAll(): Promise<Product[]> {
    return this.productsService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string): Promise<Product> {
    return this.productsService.findOne(id);
  }

  @UseGuards(JwtAuthGuard)
  @Patch(':id')
  update(
    @Param('id') id: string,
    @Body() updateProductDto: UpdateProductDto,
  ): Promise<UpdateResult> {
    return this.productsService.update(id, updateProductDto);
  }

  @UseGuards(JwtAuthGuard)
  @Delete(':id')
  remove(@Param('id') id: string): Promise<DeleteResult> {
    return this.productsService.remove(id);
  }
}

Unit Tests

Here are the unit tests for the service. The describe blocks explain pretty much what the given spec is intended to perform. As in previous tests, we are mocking a lot of objects using Jest:

src/modules/products/spec/products.service.spec.ts view raw
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsService } from '../products.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Product } from '../entities/product.entity';
import { CreateProductDto } from '../dto/create-product.dto';
import { DeleteResult, UpdateResult } from 'typeorm';
import { UpdateProductDto } from '../dto/update-product.dto';

describe('ProductsService', () => {
  let service: ProductsService;

  beforeEach(async () => {
    const createMock = jest.fn((dto: any) => {
      return dto;
    });

    const saveMock = jest.fn((dto: any) => {
      return dto;
    });

    const findOneMock = jest.fn((dto: any) => {
      return dto;
    });

    const mockRepository = jest.fn().mockImplementation(() => {
      return {
        create: createMock,
        save: saveMock,
        findOne: findOneMock,
      };
    });

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ProductsService,
        {
          provide: getRepositoryToken(Product),
          useClass: mockRepository,
        },
      ],
    }).compile();

    service = module.get<ProductsService>(ProductsService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('findAll', () => {
    it('should return an array of products', async () => {
      let result: Promise<Product[]>;

      jest.spyOn(service, 'findAll').mockImplementation(() => result);
      expect(await service.findAll()).toBe(result);
    });
  });

  describe('findOne', () => {
    it('should return a single product', async () => {
      let product: Promise<Product>;
      const testProductId = '2173dd22-42ed-4091-bb46-aaca401efa45';
      jest.spyOn(service, 'findOne').mockImplementation(() => product);
      expect(await service.findOne(testProductId)).toBe(product);
    });
  });

  describe('create', () => {
    it('should return a new product', async () => {
      let product: Promise<Product>;
      const testCreateProductDto = new CreateProductDto();
      jest.spyOn(service, 'create').mockImplementation(() => product);
      expect(await service.create(testCreateProductDto)).toBe(product);
    });
  });

  describe('update', () => {
    it('should return an update result', async () => {
      let updateResult: Promise<UpdateResult>;
      const testUpdateProductDto = new UpdateProductDto();
      const testProductId = '2173dd22-42ed-4091-bb46-aaca401efa45';

      jest.spyOn(service, 'update').mockImplementation(() => updateResult);
      expect(await service.update(testProductId, testUpdateProductDto)).toBe(
        updateResult,
      );
    });
  });

  describe('remove', () => {
    it('should return an delete result', async () => {
      let deleteResult: Promise<DeleteResult>;
      const testProductId = '2173dd22-42ed-4091-bb46-aaca401efa45';

      jest.spyOn(service, 'remove').mockImplementation(() => deleteResult);
      expect(await service.remove(testProductId)).toBe(deleteResult);
    });
  });

  describe('checkIfProductsExists', () => {
    it('should return an array of products', async () => {
      let result: Promise<Product[]>;
      const testProductIds = [
        '2173dd22-42ed-4091-bb46-aaca401efa45',
        '1884c5c2-ccab-45ed-b005-19769ffa0697',
        'da2e4c9a-439e-47f5-a289-0b193970be04',
      ];

      jest
        .spyOn(service, 'checkIfProductsExist')
        .mockImplementation(() => result);
      expect(await service.checkIfProductsExist(testProductIds)).toBe(result);
    });
  });
});

Here is the spec passing:

Products Service Spec

Here are the unit tests for the controller. We have only added a single spec here to test that the controller has been defined, since the bulk of the controller tests will be done later via end to end tests:

src/modules/products/spec/products.controller.spec.ts view raw
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsController } from '../products.controller';
import { ProductsService } from '../products.service';

describe('ProductsController', () => {
  let controller: ProductsController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [ProductsController],
      providers: [
        {
          provide: ProductsService,
          useValue: {},
        },
      ],
    }).compile();

    controller = module.get<ProductsController>(ProductsController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

Here is the spec passing:

Products Controller Spec

Postman Tests

As usual, for good measure we carry out some Postman tests on our endpoints to see them in action:

Get Products

Get products

Fetch a single product

Fetch a single product

Creating a product

Create product

Update a product

Update product

Delete a product

Delete product