From 7315f25f672fbf89384d497ecee38924c8bbf38e Mon Sep 17 00:00:00 2001 From: rafamagalhas Date: Thu, 10 Aug 2023 14:36:58 -0300 Subject: [PATCH 1/3] feat: add error handler in app --- src/app.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 6583da8..3f89bbf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,8 @@ -import express from 'express'; +import { StatusCodes } from 'http-status-codes'; +import express, { Request, Response, NextFunction } from 'express'; import morgan from 'morgan'; import { routes } from './routes'; +import { logger } from './utils/winston'; const app = express(); @@ -9,4 +11,9 @@ app.use(morgan('tiny')); app.use('/api/v1', routes); +app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error(error); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: error.message }); +}) + export default app; From a6f63f4e70db31117b8faa3af7f48fb906c1e06d Mon Sep 17 00:00:00 2001 From: rafamagalhas Date: Thu, 10 Aug 2023 14:38:00 -0300 Subject: [PATCH 2/3] feat: add product resource --- src/business/product.ts | 25 ++++ src/controllers/product.ts | 52 +++++++ src/interfaces/product.ts | 15 ++ src/models/product.ts | 45 ++++++ src/parsers/product.ts | 32 +++++ src/routes/index.ts | 6 +- src/routes/product.ts | 14 ++ tests/integration/products/index.test.ts | 128 ++++++++++++++++++ .../products/mocks/products-mock.ts | 86 ++++++++++++ 9 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 src/business/product.ts create mode 100644 src/controllers/product.ts create mode 100644 src/interfaces/product.ts create mode 100644 src/models/product.ts create mode 100644 src/parsers/product.ts create mode 100644 src/routes/product.ts create mode 100644 tests/integration/products/index.test.ts create mode 100644 tests/integration/products/mocks/products-mock.ts diff --git a/src/business/product.ts b/src/business/product.ts new file mode 100644 index 0000000..3a29226 --- /dev/null +++ b/src/business/product.ts @@ -0,0 +1,25 @@ +import { product } from "./../models/product"; +import { IProduct } from "../interfaces/product"; +import { productListParser, productParser } from "../parsers/product"; + +export async function createBusiness(data: IProduct): Promise { + if (product.checkExists(data.sku)) { + throw new Error("Product already exists"); + } + + return product.create(data); +} + +export async function findAll(): Promise { + const productList = product.findAll(); + return productListParser(productList); +} + +export async function findBySku(sku: number): Promise { + const response = product.findBySku(sku); + if (response.length === 0) { + throw new Error("Product not found"); + } + + return productParser(response[0]); +} diff --git a/src/controllers/product.ts b/src/controllers/product.ts new file mode 100644 index 0000000..e0b6832 --- /dev/null +++ b/src/controllers/product.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from "express"; +import { StatusCodes } from "http-status-codes"; +import { logger } from "../utils/winston"; +import { createBusiness, findAll, findBySku } from "../business/product"; + +export async function createController( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const response = await createBusiness(req.body); + return res.status(StatusCodes.CREATED).json(response); + } catch (error) { + logger.error(error); + + if (error.message === "Product already exists") { + return res.status(StatusCodes.CONFLICT).json({ message: error.message }); + } + next(error); + } +} + +export async function findAllController( + _req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const response = await findAll(); + return res.status(StatusCodes.OK).json(response); + } catch (error) { + next(error); + } +} + +export async function findBySkuController( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const response = await findBySku(parseInt(req.params.sku)); + return res.status(StatusCodes.OK).json(response); + } catch (error) { + if (error.message === "Product not found") { + return res.status(StatusCodes.NOT_FOUND).json({ message: error.message }); + } + + next(error); + } +} diff --git a/src/interfaces/product.ts b/src/interfaces/product.ts new file mode 100644 index 0000000..433a44a --- /dev/null +++ b/src/interfaces/product.ts @@ -0,0 +1,15 @@ +interface IWarehouse { + locality: string; + quantity: number; + type: string; +} + +export interface IProduct { + sku: number; + name: string; + inventory: { + quantity?: number; + warehouses: IWarehouse[]; + }; + isMarketable?: boolean; +} diff --git a/src/models/product.ts b/src/models/product.ts new file mode 100644 index 0000000..015da3f --- /dev/null +++ b/src/models/product.ts @@ -0,0 +1,45 @@ +import { IProduct } from "../interfaces/product"; + +class ProductModel { + private productsDatabase: IProduct[] = []; + + create(data: IProduct): number | null { + this.productsDatabase.push(data); + return data.sku; + } + + findAll(): Array { + return this.productsDatabase; + } + + findBySku(sku: number): IProduct[] { + return this.productsDatabase.filter((item) => { + return item.sku === sku; + }); + } + + update(productIndex: number, data: IProduct): IProduct { + this.productsDatabase[productIndex] = data; + return data; + } + + remove(sku: number): void { + this.productsDatabase = this.productsDatabase.filter(function (obj) { + return obj.sku !== sku; + }); + } + + checkExists(sku: number): number | null { + if (this.productsDatabase.some((el) => el.sku === sku)) { + return sku; + } + + return null; + } + + getProductIndex(sku: number): number { + return this.productsDatabase.findIndex((item) => item.sku === sku); + } +} + +export const product = new ProductModel(); diff --git a/src/parsers/product.ts b/src/parsers/product.ts new file mode 100644 index 0000000..e85dc58 --- /dev/null +++ b/src/parsers/product.ts @@ -0,0 +1,32 @@ +import { IProduct } from "../interfaces/product"; + +export function productParser(product: IProduct): IProduct { + product.inventory.quantity = product.inventory.warehouses.reduce( + (acc, current) => acc + current.quantity, + 0 + ); + product.isMarketable = false; + + if (product.inventory.quantity > 0) { + product.isMarketable = true; + } + + return { + sku: product.sku, + name: product.name, + inventory: { + quantity: product.inventory.quantity, + warehouses: product.inventory.warehouses + }, + isMarketable: product.isMarketable + }; +} + +export function productListParser(productList: IProduct[]): IProduct[] { + const parsedProductList: IProduct[] = []; + productList.forEach((product) => { + parsedProductList.push(productParser(product)); + }); + + return parsedProductList; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 7f178ad..4523aa9 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,8 +1,10 @@ import { Router } from "express"; -import healthRoutes from './health'; +import healthRoutes from "./health"; +import productRoutes from "./product"; const routes: Router = Router(); -routes.use('/health', healthRoutes); +routes.use("/health", healthRoutes); +routes.use("/products", productRoutes); export { routes }; diff --git a/src/routes/product.ts b/src/routes/product.ts new file mode 100644 index 0000000..6d05e6c --- /dev/null +++ b/src/routes/product.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { + createController, + findAllController, + findBySkuController, +} from "../controllers/product"; + +const routes = Router(); + +routes.post("/", createController); +routes.get("/", findAllController); +routes.get("/:sku", findBySkuController); + +export default routes; diff --git a/tests/integration/products/index.test.ts b/tests/integration/products/index.test.ts new file mode 100644 index 0000000..db5cc6e --- /dev/null +++ b/tests/integration/products/index.test.ts @@ -0,0 +1,128 @@ +import request from "supertest"; +import app from "../../../src/app"; +import * as mocks from "./mocks/products-mock"; + +describe("/api/v1/product endpoint", () => { + test("[POST] should return 201 when product was created with success", async () => { + const res = await request(app) + .post("/api/v1/products") + .send(mocks.productOne); + expect(res.statusCode).toEqual(201); + + expect(res.body).toBeTruthy(); + expect(res.body).toBe(43264); + }); + + test("[POST] should return 201 when another product was created with success", async () => { + const res = await request(app) + .post("/api/v1/products") + .send(mocks.productTwo); + expect(res.statusCode).toEqual(201); + + expect(res.body).toBeTruthy(); + expect(res.body).toBe(43562); + }); + + test("[POST] should return 201 when another product was created with success", async () => { + const res = await request(app) + .post("/api/v1/products") + .send(mocks.productThree); + expect(res.statusCode).toEqual(201); + + expect(res.body).toBeTruthy(); + expect(res.body).toBe(44985); + }); + + test("[POST] should return 201 when another product was created with success", async () => { + const res = await request(app) + .post("/api/v1/products") + .send(mocks.productFour); + expect(res.statusCode).toEqual(201); + + expect(res.body).toBeTruthy(); + expect(res.body).toBe(44989); + }); + + test("[POST] should return 409 when product was created already exists", async () => { + const res = await request(app) + .post("/api/v1/products") + .send(mocks.productOne); + expect(res.statusCode).toEqual(409); + + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Product already exists"); + }); + + test("[GET] should return 200 when products exists", async () => { + const res = await request(app).get("/api/v1/products"); + expect(res.statusCode).toEqual(200); + + expect(res.body).toBeTruthy(); + expect(res.body).toHaveLength(4); + expect(res.body[0]).toHaveProperty("sku", 43264); + expect(res.body[0]).toHaveProperty( + "name", + "L'Oréal Professionnel Expert Absolut Repair Cortex Lipidium - Máscara de Reconstrução 500g" + ); + expect(res.body[0]).toHaveProperty("inventory"); + expect(res.body[0].inventory).toHaveProperty("quantity", 15); + expect(res.body[0].inventory).toHaveProperty("warehouses"); + expect(res.body[0].inventory.warehouses[0]).toHaveProperty( + "locality", + "SP" + ); + expect(res.body[0].inventory.warehouses[0]).toHaveProperty("quantity", 12); + expect(res.body[0].inventory.warehouses[0]).toHaveProperty( + "type", + "ECOMMERCE" + ); + expect(res.body[0].inventory.warehouses[1]).toHaveProperty( + "locality", + "MOEMA" + ); + expect(res.body[0].inventory.warehouses[1]).toHaveProperty("quantity", 3); + expect(res.body[0].inventory.warehouses[1]).toHaveProperty( + "type", + "PHYSICAL_STORE" + ); + expect(res.body[0]).toHaveProperty("isMarketable", true); + }); + + test("[GET] should return 200 when get product by id and isMarketable is false", async () => { + const res = await request(app).get("/api/v1/products/44989"); + expect(res.statusCode).toEqual(200); + + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("sku", 44989); + expect(res.body).toHaveProperty("name", "Produto Teste"); + expect(res.body).toHaveProperty("inventory"); + expect(res.body.inventory).toHaveProperty("quantity", 0); + expect(res.body.inventory).toHaveProperty("warehouses"); + expect(res.body.inventory.warehouses[0]).toHaveProperty("locality", "SP"); + expect(res.body.inventory.warehouses[0]).toHaveProperty("quantity", 0); + expect(res.body.inventory.warehouses[0]).toHaveProperty( + "type", + "ECOMMERCE" + ); + + expect(res.body.inventory.warehouses[1]).toHaveProperty( + "locality", + "MOEMA" + ); + expect(res.body.inventory.warehouses[1]).toHaveProperty("quantity", 0); + expect(res.body.inventory.warehouses[1]).toHaveProperty( + "type", + "PHYSICAL_STORE" + ); + expect(res.body.inventory.warehouses[2]).toHaveProperty( + "locality", + "FRANCA" + ); + expect(res.body.inventory.warehouses[2]).toHaveProperty("quantity", 0); + expect(res.body.inventory.warehouses[2]).toHaveProperty( + "type", + "PHYSICAL_STORE" + ); + expect(res.body).toHaveProperty("isMarketable", false); + }); +}); diff --git a/tests/integration/products/mocks/products-mock.ts b/tests/integration/products/mocks/products-mock.ts new file mode 100644 index 0000000..45cc405 --- /dev/null +++ b/tests/integration/products/mocks/products-mock.ts @@ -0,0 +1,86 @@ +export const productOne = { + sku: 43264, + name: "L'Oréal Professionnel Expert Absolut Repair Cortex Lipidium - Máscara de Reconstrução 500g", + inventory: { + warehouses: [ + { + locality: "SP", + quantity: 12, + type: "ECOMMERCE", + }, + { + locality: "MOEMA", + quantity: 3, + type: "PHYSICAL_STORE", + }, + ], + }, +}; + +export const productTwo = { + sku: 43562, + name: "Malbec", + inventory: { + warehouses: [ + { + locality: "SP", + quantity: 12, + type: "ECOMMERCE", + }, + { + locality: "FRANCA", + quantity: 10, + type: "PHYSICAL_STORE", + }, + ], + }, +}; + +export const productThree = { + sku: 44985, + name: "214", + inventory: { + warehouses: [ + { + locality: "SP", + quantity: 7, + type: "ECOMMERCE", + }, + { + locality: "MOEMA", + quantity: 3, + type: "PHYSICAL_STORE", + }, + { + locality: "FRANCA", + quantity: 30, + type: "PHYSICAL_STORE", + }, + ], + }, +}; + +export const productFour = { + sku: 44989, + name: "Produto Teste", + inventory: { + warehouses: [ + { + locality: "SP", + quantity: 0, + type: "ECOMMERCE", + }, + { + locality: "MOEMA", + quantity: 0, + type: "PHYSICAL_STORE", + }, + { + locality: "FRANCA", + quantity: 0, + type: "PHYSICAL_STORE", + }, + ], + }, +}; + From 7b41d4b2440926e265e9c0b0b02dd7d62d43a689 Mon Sep 17 00:00:00 2001 From: rafamagalhas Date: Thu, 10 Aug 2023 14:38:15 -0300 Subject: [PATCH 3/3] refactor: health route resource --- src/business/health.ts | 4 ++-- src/controllers/health.ts | 18 ++++++++++-------- src/routes/health.ts | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/business/health.ts b/src/business/health.ts index 0e40e78..bc836f3 100644 --- a/src/business/health.ts +++ b/src/business/health.ts @@ -5,7 +5,7 @@ export function healthcheck() { return { uptime: process.uptime(), - message: 'OK', - timestamp: Date.now() + message: "OK", + timestamp: Date.now(), }; } diff --git a/src/controllers/health.ts b/src/controllers/health.ts index 96e8551..5790f61 100644 --- a/src/controllers/health.ts +++ b/src/controllers/health.ts @@ -1,15 +1,17 @@ -import { Request, Response } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import { healthcheck } from '../business/health'; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { StatusCodes } from "http-status-codes"; +import { healthcheck } from "../business/health"; -async function health(_req: Request, res: Response): Promise { +export async function health( + _req: Request, + res: Response, + next: NextFunction +): Promise { try { const response = healthcheck(); return res.status(StatusCodes.OK).json(response); } catch (error) { - console.log(error); - return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({message: error.message }); + next(error); } } - -export default health; diff --git a/src/routes/health.ts b/src/routes/health.ts index 93ca4ce..d5e8444 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import health from "../controllers/health"; +import { health } from "../controllers/health"; const routes = Router();