From da3a494cb3a8d45775a4e07b36466355c0defb17 Mon Sep 17 00:00:00 2001 From: Benson-Ogheneochuko Date: Wed, 24 Jul 2024 19:01:14 +0100 Subject: [PATCH] feat: create product feature --- src/controllers/ProductController.ts | 41 +++++- src/middleware/product.ts | 16 +++ src/models/product.ts | 49 +++++++- src/routes/product.ts | 19 ++- src/services/product.services.ts | 12 +- src/test/product.spec.ts | 180 +++++++++++++++++++++++++++ 6 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 src/middleware/product.ts create mode 100644 src/test/product.spec.ts diff --git a/src/controllers/ProductController.ts b/src/controllers/ProductController.ts index 40636bef..6bd15510 100644 --- a/src/controllers/ProductController.ts +++ b/src/controllers/ProductController.ts @@ -1,5 +1,6 @@ import { Request, Response } from "express"; import { ProductService } from "../services/product.services"; // Adjust the import path as necessary +import { ProductDTO } from "../models"; export class ProductController { private productService: ProductService; @@ -434,7 +435,45 @@ export class ProductController { * message: * type: string */ - async createProduct(req: Request, res: Response) {} + async createProduct(req: Request, res: Response) { + try { + const { user } = req; + const { sanitizedData } = req.body; + + if (!user) { + return res.status(401).json({ + status: "unsuccessful", + status_code: 401, + message: "Unauthorized User", + }); + } + + const productDTO = new ProductDTO(sanitizedData); + await productDTO.validate(); + + const product = await this.productService.createProduct({ + ...sanitizedData, + user, + }); + + const { user: _, ...productWithoutUser } = product; + + return res.status(201).json({ + status: "success", + status_code: 201, + message: "Product created successfully", + data: { productWithoutUser }, + }); + } catch (error) { + // console.error('Error creating product:', error) + return res.status(500).json({ + status: "unsuccessful", + status_code: 500, + message: "Internal server error", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } /** * @swagger diff --git a/src/middleware/product.ts b/src/middleware/product.ts new file mode 100644 index 00000000..5028b741 --- /dev/null +++ b/src/middleware/product.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from "express"; + +import { body, validationResult } from "express-validator"; +// Middleware to validate and sanitize product details +export const validateProductDetails = [ + body("name").trim().escape(), + body("description").trim().escape(), + body("category").trim().escape(), + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + next(); + }, +]; diff --git a/src/models/product.ts b/src/models/product.ts index 962ee4a8..bf3a5935 100644 --- a/src/models/product.ts +++ b/src/models/product.ts @@ -1,7 +1,14 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; -import { User } from './user'; -import ExtendedBaseEntity from './extended-base-entity'; - +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm"; +import { User } from "./user"; +import ExtendedBaseEntity from "./extended-base-entity"; +import { + IsString, + IsNumber, + IsPositive, + MinLength, + validateOrReject, + IsNotEmpty, +} from "class-validator"; /** * @swagger @@ -34,7 +41,7 @@ import ExtendedBaseEntity from './extended-base-entity'; @Entity() export class Product extends ExtendedBaseEntity { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; @Column() @@ -52,3 +59,35 @@ export class Product extends ExtendedBaseEntity { @ManyToOne(() => User, (user) => user.products) user: User; } + +export class ProductDTO { + @IsString({ message: "Name must be a string" }) + @IsNotEmpty({ message: "Name must not be empty" }) + @MinLength(1, { message: "Name must not be empty" }) + name: string; + + @IsString({ message: "Description must be a string" }) + @IsNotEmpty({ message: "Description must not be empty" }) + @MinLength(1, { message: "Description must not be empty" }) + description: string; + + @IsNumber({}, { message: "Price must be a number" }) + @IsPositive({ message: "Price must be positive" }) + price: number; + + @IsString({ message: "Category must be a string" }) + @IsNotEmpty({ message: "Category must not be empty" }) + @MinLength(1, { message: "Category must not be empty" }) + category: string; + + async validate() { + await validateOrReject(this, { + validationError: { target: false, value: true }, + skipMissingProperties: false, + }); + } + + constructor(data: Partial) { + Object.assign(this, data); + } +} diff --git a/src/routes/product.ts b/src/routes/product.ts index ee3c1ec1..f971cf28 100644 --- a/src/routes/product.ts +++ b/src/routes/product.ts @@ -1,6 +1,7 @@ -import express from 'express'; -import ProductController from '../controllers/ProductController'; -import { authMiddleware } from '../middleware'; +import express from "express"; +import ProductController from "../controllers/ProductController"; +import { authMiddleware } from "../middleware"; +import { validateProductDetails } from "../middleware/product"; const productRouter = express.Router(); const productController = new ProductController(); @@ -85,9 +86,15 @@ const productController = new ProductController(); */ productRouter.get( - '/', + "/", authMiddleware, - productController.getProductPagination.bind(productController) + productController.getProductPagination.bind(productController), ); - +productRouter + .route("/") + .post( + validateProductDetails, + authMiddleware, + productController.createProduct.bind(productController) + ); export { productRouter }; diff --git a/src/services/product.services.ts b/src/services/product.services.ts index 476bf4cb..8ff63fd4 100644 --- a/src/services/product.services.ts +++ b/src/services/product.services.ts @@ -1,6 +1,7 @@ import { Product } from "../models/product"; import { IProduct } from "../types"; import AppDataSource from "../data-source"; +import { ProductDTO } from "../models/product"; export class ProductService { getPaginatedProducts( @@ -13,9 +14,7 @@ export class ProductService { } private productRepository = AppDataSource.getRepository(Product); - public async getProductPagination( - query: any, - ): Promise<{ + public async getProductPagination(query: any): Promise<{ page: number; limit: number; totalProducts: number; @@ -57,4 +56,11 @@ export class ProductService { throw new Error(err.message); } } + + public async createProduct( + productDetails: Partial, + ): Promise { + const product = this.productRepository.create(productDetails); + return await this.productRepository.save(product); + } } diff --git a/src/test/product.spec.ts b/src/test/product.spec.ts new file mode 100644 index 00000000..b299008a --- /dev/null +++ b/src/test/product.spec.ts @@ -0,0 +1,180 @@ +// @ts-nocheck +import { Request, Response, NextFunction } from "express"; +import { ProductController } from "../controllers/ProductController"; +import { ProductService } from "../services"; +import { ProductDTO } from "../models/product"; +import { validateProductDetails } from "../middleware/product"; +import { validationResult, body } from "express-validator"; + +// Mock the external dependencies +jest.mock("../services/product.services.ts"); +jest.mock("../models/product"); +jest.mock("express-validator", () => ({ + body: jest.fn(() => ({ + trim: jest.fn().mockReturnThis(), + escape: jest.fn().mockReturnThis(), + })), + validationResult: jest.fn(), +})); + +describe("ProductController", () => { + let productController: ProductController; + let mockRequest: Partial; + let mockResponse: Partial; + let mockUser: any; + let nextFunction: jest.Mock; + + beforeEach(() => { + productController = new ProductController(); + mockUser = { id: "1", name: "sampleUser", email: "user@sample.com" }; + mockRequest = { + body: { + sanitizedData: { + name: "Test Product", + description: "Test Description", + price: 10.99, + category: "Test Category", + }, + }, + user: mockUser, + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + nextFunction = jest.fn(); + (validationResult as unknown as jest.Mock).mockReturnValue({ + isEmpty: jest.fn().mockReturnValue(true), + array: jest.fn().mockReturnValue([]), + }); + nextFunction.mockClear(); + + // Mock ProductService methods + (ProductService.prototype.createProduct as jest.Mock).mockResolvedValue({ + id: "product123", + user: mockUser, + ...mockRequest.body.sanitizedData, + }); + + // Mock ProductDTO validate method + (ProductDTO.prototype.validate as jest.Mock) = jest + .fn() + .mockResolvedValue(undefined); + }); + + const runMiddleware = async ( + middleware: any, + req: Partial, + res: Partial, + next: NextFunction, + ) => { + if (typeof middleware === "function") { + await middleware(req as Request, res as Response, next); + } + }; + + it("should create a product successfully", async () => { + for (const middleware of validateProductDetails) { + await runMiddleware(middleware, mockRequest, mockResponse, nextFunction); + } + await productController.createProduct( + mockRequest as Request, + mockResponse as Response, + ); + + expect(nextFunction).toHaveBeenCalled(); + expect(ProductDTO.prototype.validate).toHaveBeenCalled(); + expect(ProductService.prototype.createProduct).toHaveBeenCalledWith({ + ...mockRequest.body.sanitizedData, + user: mockUser, + }); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: "success", + status_code: 201, + message: "Product created successfully", + data: { + productWithoutUser: { + id: "product123", + ...mockRequest.body.sanitizedData, + }, + }, + }); + }); + + it("should return 401 if user is not authenticated", async () => { + mockRequest.user = undefined; + + await productController.createProduct( + mockRequest as Request, + mockResponse as Response, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: "unsuccessful", + status_code: 401, + message: "Unauthorized User", + }); + }); + + it("should handle errors and return 500", async () => { + const errorMessage = "Test error"; + (ProductService.prototype.createProduct as jest.Mock).mockRejectedValue( + new Error(errorMessage), + ); + + await productController.createProduct( + mockRequest as Request, + mockResponse as Response, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: "unsuccessful", + status_code: 500, + message: "Internal server error", + error: errorMessage, + }); + }); + + it("should validate and sanitize inputs", async () => { + mockRequest.body = { + name: ' Test ', + description: " Description with "quotes" ", + category: " Unsafe Category & More ", + price: 10.99, + }; + + for (const middleware of validateProductDetails) { + await runMiddleware(middleware, mockRequest, mockResponse, nextFunction); + } + + expect(body).toHaveBeenCalledWith("name"); + expect(body).toHaveBeenCalledWith("description"); + expect(body).toHaveBeenCalledWith("category"); + expect(nextFunction).toHaveBeenCalled(); + }); + + it("should return 400 if validation fails", async () => { + const mockErrors = [ + { msg: "Name is required", param: "name", location: "body" }, + ]; + + (validationResult as unknown as jest.Mock).mockReturnValue({ + isEmpty: jest.fn().mockReturnValue(false), + array: jest.fn().mockReturnValue(mockErrors), + }); + + await runMiddleware( + validateProductDetails[validateProductDetails.length - 1], + mockRequest, + mockResponse, + nextFunction, + ); + + expect(nextFunction).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ errors: mockErrors }); + }); +});