diff --git a/package-lock.json b/package-lock.json index a0dc88a..7515af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "http-status-codes": "^2.2.0", + "joi": "^17.9.2", "morgan": "^1.10.0", "ts-jest": "^29.1.1", "winston": "^3.10.0" @@ -701,6 +702,19 @@ "kuler": "^2.0.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1031,6 +1045,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3337,6 +3369,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.9.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz", + "integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 217d0ce..df723b1 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "http-status-codes": "^2.2.0", + "joi": "^17.9.2", "morgan": "^1.10.0", "ts-jest": "^29.1.1", "winston": "^3.10.0" diff --git a/src/business/product.ts b/src/business/product.ts index 3a29226..580f46f 100644 --- a/src/business/product.ts +++ b/src/business/product.ts @@ -23,3 +23,21 @@ export async function findBySku(sku: number): Promise { return productParser(response[0]); } + +export async function update(sku: number, payload: IProduct): Promise { + const productIndex: number = product.getProductIndex(sku); + + if (productIndex === -1) { + throw new Error("Product not found"); + } + payload.sku = sku; + const response = product.update(productIndex, payload); + return productParser(response); +} + +export async function remove(sku: number): Promise { + if (!product.checkExists(sku)) { + throw new Error("Product not found"); + } + product.remove(sku); +} diff --git a/src/controllers/product.ts b/src/controllers/product.ts index e0b6832..21f9ff4 100644 --- a/src/controllers/product.ts +++ b/src/controllers/product.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; +import { createBusiness, findAll, findBySku, remove, update } from "../business/product"; import { StatusCodes } from "http-status-codes"; import { logger } from "../utils/winston"; -import { createBusiness, findAll, findBySku } from "../business/product"; export async function createController( req: Request, @@ -12,9 +12,8 @@ export async function createController( const response = await createBusiness(req.body); return res.status(StatusCodes.CREATED).json(response); } catch (error) { - logger.error(error); - if (error.message === "Product already exists") { + logger.error(error.message); return res.status(StatusCodes.CONFLICT).json({ message: error.message }); } next(error); @@ -44,9 +43,45 @@ export async function findBySkuController( return res.status(StatusCodes.OK).json(response); } catch (error) { if (error.message === "Product not found") { + logger.info(error.message); return res.status(StatusCodes.NOT_FOUND).json({ message: error.message }); } + next(error); + } +} +export async function updateController( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const response = await update(parseInt(req.params.sku), req.body); + return res.status(StatusCodes.OK).json(response); + } catch (error) { + if (error.message === "Product not found") { + logger.error(error); + return res.status(StatusCodes.NOT_FOUND).json({ message: error.message }); + } next(error); } } + +export async function removeController( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + await remove(parseInt(req.params.sku)); + return res.status(StatusCodes.NO_CONTENT).send(); + } catch (error) { + if (error.message === "Product not found") { + logger.error(error.message); + return res.status(StatusCodes.NOT_FOUND).json({ message: error.message }); + } + next(error); + } +} + + diff --git a/src/interfaces/validator.ts b/src/interfaces/validator.ts new file mode 100644 index 0000000..04b7c89 --- /dev/null +++ b/src/interfaces/validator.ts @@ -0,0 +1,7 @@ +import { Schema } from 'joi' + +export interface IValidator { + params?: Schema + query?: Schema + body?: Schema +} diff --git a/src/middlewares/validator.ts b/src/middlewares/validator.ts new file mode 100644 index 0000000..fcada4e --- /dev/null +++ b/src/middlewares/validator.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from "express"; +import { IValidator } from "../interfaces/validator"; +import { StatusCodes } from "http-status-codes"; + +class Validator { + static schema: IValidator; + + static validate(schema: IValidator) { + return this.makeValidation.bind(schema); + } + + static makeValidation(req: Request, res: Response, next: NextFunction) { + const errors = []; + const schema = this; + + Object.keys(schema).map((key) => { + const { error } = schema[key].validate(req[key]); + + if (error) errors.push({ type: `${key} validation`, error }); + + return key; + }); + + if (errors.length === 0) { + return next(); + } + + return res.status(StatusCodes.BAD_REQUEST).json({ message: "Validation Error", errors }); + } +} + +export { Validator }; diff --git a/src/routes/product.ts b/src/routes/product.ts index 6d05e6c..7cb0890 100644 --- a/src/routes/product.ts +++ b/src/routes/product.ts @@ -3,12 +3,17 @@ import { createController, findAllController, findBySkuController, + updateController, + removeController, } from "../controllers/product"; +import { Schema } from "../schemas/product"; const routes = Router(); -routes.post("/", createController); +routes.post("/", Schema.create, createController); routes.get("/", findAllController); -routes.get("/:sku", findBySkuController); +routes.get("/:sku", Schema.bySku, findBySkuController); +routes.put("/:sku", Schema.bySku, Schema.update, updateController); +routes.delete("/:sku", Schema.bySku, removeController); export default routes; diff --git a/src/schemas/product.ts b/src/schemas/product.ts new file mode 100644 index 0000000..141a814 --- /dev/null +++ b/src/schemas/product.ts @@ -0,0 +1,50 @@ +import Joi from "joi"; +import { Validator } from "../middlewares/validator"; + +class Schema extends Validator { + static productBody = { + name: Joi.string().required(), + inventory: Joi.object({ + warehouses: Joi.array().items( + Joi.object({ + locality: Joi.string().required(), + quantity: Joi.number().required(), + type: Joi.string().required().valid("ECOMMERCE", "PHYSICAL_STORE"), + }) + ), + }), + }; + + static get bySku() { + const schema = { + params: Joi.object({ + sku: Joi.number().min(1).required(), + }), + }; + + return this.validate(schema); + } + + static get create() { + const schema = { + body: Joi.object({ + sku: Joi.number().required(), + ...this.productBody + }), + }; + + return this.validate(schema); + } + + static get update() { + const schema = { + body: Joi.object({ + ...this.productBody + }), + }; + + return this.validate(schema); + } +} + +export { Schema }; diff --git a/tests/integration/products/fail.test.ts b/tests/integration/products/fail.test.ts new file mode 100644 index 0000000..1395536 --- /dev/null +++ b/tests/integration/products/fail.test.ts @@ -0,0 +1,87 @@ +import request from "supertest"; +import app from "../../../src/app"; +import * as mocks from "./mocks/products-mock"; +import * as business from "../../../src/business/product"; + +describe("/api/v1/product endpoint fail test", () => { + test("[POST] should return 500 when try create a product", async () => { + jest.spyOn(business, "createBusiness").mockRejectedValue(new Error("Erro test")) + + + const res = await request(app) + .post("/api/v1/products") + .send(mocks.productOne); + + expect(res.statusCode).toEqual(500); + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Erro test"); + }); + + test("[GET] should return 400 when get product by id with wrong value", async () => { + const res = await request(app).get("/api/v1/products/aaaa"); + + expect(res.statusCode).toEqual(400); + }) + + test("[GET] should return 500 when try to get all products", async () => { + jest.spyOn(business, "findAll").mockRejectedValue(new Error("Erro test")) + + + const res = await request(app) + .get("/api/v1/products") + + expect(res.statusCode).toEqual(500); + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Erro test"); + }); + + test("[GET] should return 500 when try to get product by sku", async () => { + jest.spyOn(business, "findBySku").mockRejectedValue(new Error("Erro test")) + + + const res = await request(app) + .get("/api/v1/products/784") + + expect(res.statusCode).toEqual(500); + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Erro test"); + }); + + test("[PUT] should return 500 when try to update a product by sku", async () => { + jest.spyOn(business, "update").mockRejectedValue(new Error("Erro test")) + + + const res = await request(app) + .put("/api/v1/products/784").send({name: "Malbec", + inventory: { + warehouses: [ + { + locality: "SP", + quantity: 10, + type: "ECOMMERCE", + }, + { + locality: "FRANCA", + quantity: 15, + type: "PHYSICAL_STORE", + }, + ], + },}) + + expect(res.statusCode).toEqual(500); + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Erro test"); + }); + + test("[DELETE] should return 500 when try to remove product by sku", async () => { + jest.spyOn(business, "remove").mockRejectedValue(new Error("Erro test")) + + + const res = await request(app) + .delete("/api/v1/products/784") + + expect(res.statusCode).toEqual(500); + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Erro test"); + }); +}); diff --git a/tests/integration/products/index.test.ts b/tests/integration/products/index.test.ts index db5cc6e..c142728 100644 --- a/tests/integration/products/index.test.ts +++ b/tests/integration/products/index.test.ts @@ -125,4 +125,95 @@ describe("/api/v1/product endpoint", () => { ); expect(res.body).toHaveProperty("isMarketable", false); }); + + test("[GET] should return 404 when get product by id not found", async () => { + const res = await request(app).get("/api/v1/products/9999"); + expect(res.statusCode).toEqual(404); + + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Product not found"); + }); + + test("[PUT] should return 200 when update product with success", async () => { + const res = await request(app) + .put("/api/v1/products/43562") + .send({ + name: "Malbec", + inventory: { + warehouses: [ + { + locality: "SP", + quantity: 10, + type: "ECOMMERCE", + }, + { + locality: "FRANCA", + quantity: 15, + type: "PHYSICAL_STORE", + }, + ], + }, + }); + + expect(res.statusCode).toEqual(200); + + expect(res.body).toBeTruthy(); + expect(res.body.inventory.warehouses[0]).toHaveProperty("locality", "SP"); + expect(res.body.inventory.warehouses[0]).toHaveProperty("quantity", 10); + expect(res.body.inventory.warehouses[0]).toHaveProperty( + "type", + "ECOMMERCE" + ); + + expect(res.body.inventory.warehouses[1]).toHaveProperty( + "locality", + "FRANCA" + ); + expect(res.body.inventory.warehouses[1]).toHaveProperty("quantity", 15); + expect(res.body.inventory.warehouses[1]).toHaveProperty( + "type", + "PHYSICAL_STORE" + ); + }); + + test("[PUT] should return 404 when product sku not found", async () => { + const res = await request(app) + .put("/api/v1/products/9999") + .send({ + name: "Malbec", + inventory: { + warehouses: [ + { + locality: "SP", + quantity: 10, + type: "ECOMMERCE", + }, + { + locality: "FRANCA", + quantity: 15, + type: "PHYSICAL_STORE", + }, + ], + }, + }); + expect(res.statusCode).toEqual(404); + + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Product not found"); + }); + + test("[DELETE] should return 200 when product was removed with success", async () => { + const res = await request(app).delete("/api/v1/products/43562"); + expect(res.statusCode).toEqual(204); + + expect.not.objectContaining(res.body); + }); + + test("[DELETE] should return 404 when product sku not found", async () => { + const res = await request(app).delete("/api/v1/products/9999"); + expect(res.statusCode).toEqual(404); + + expect(res.body).toBeTruthy(); + expect(res.body).toHaveProperty("message", "Product not found"); + }); });