This article is Part 7 in a 10 Part Series.
Today we shall be creating the products module for our backend application, starting as usual with the products module. In case of any issues, you can refer to my Github repository here .
Products Module
Here’s the products module file:
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:
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:
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:
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:
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).
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:
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:
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:
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:
Postman Tests
As usual, for good measure we carry out some Postman tests on our endpoints to see them in action:
Get Products
Fetch a single product
Creating a product
Update a product
Delete a product