From 9892b15ceb115071e370838b2cf56f8e289a9c81 Mon Sep 17 00:00:00 2001 From: marcos parajua Date: Tue, 30 Apr 2024 18:43:41 +0200 Subject: [PATCH] add controller and middlewares --- package-lock.json | 143 ++++++++++++++++- package.json | 2 + src/controllers/base.controller.spec.ts | 179 ++++++++++++++++++++++ src/controllers/baseController.ts | 93 +++++++++++ src/controllers/users. controller.spec.ts | 166 ++++++++++++++++++++ src/controllers/users.controller.ts | 95 ++++++++++++ src/entities/user.schema.ts | 4 +- src/middleware/files.interceptor.spec.ts | 66 ++++++++ src/middleware/files.interceptor.ts | 71 +++++++++ src/repositories/users.sql.repo.spec.ts | 15 ++ src/services/auth.service.spec.ts | 57 +++++++ src/services/auth.service.ts | 29 ++++ src/tools/db.connect.ts | 9 ++ 13 files changed, 924 insertions(+), 5 deletions(-) create mode 100644 src/controllers/base.controller.spec.ts create mode 100644 src/controllers/baseController.ts create mode 100644 src/controllers/users. controller.spec.ts create mode 100644 src/controllers/users.controller.ts create mode 100644 src/middleware/files.interceptor.spec.ts create mode 100644 src/middleware/files.interceptor.ts create mode 100644 src/services/auth.service.spec.ts create mode 100644 src/services/auth.service.ts create mode 100644 src/tools/db.connect.ts diff --git a/package-lock.json b/package-lock.json index 4c1a647..07fe213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "joi": "^17.12.3", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "nodemon": "^3.1.0" }, "devDependencies": { @@ -31,6 +32,7 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.11", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", @@ -1590,6 +1592,15 @@ "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", @@ -1979,6 +1990,11 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -2346,8 +2362,18 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } }, "node_modules/bytes": { "version": "3.1.2", @@ -2614,6 +2640,47 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -2663,6 +2730,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -4105,6 +4177,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5149,6 +5226,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -5240,6 +5325,34 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mute-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", @@ -5776,6 +5889,11 @@ "node": ">=16.13" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6322,6 +6440,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6663,6 +6789,11 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/typescript": { "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", @@ -6866,6 +6997,14 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index d66ecf4..854b320 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.11", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", @@ -55,6 +56,7 @@ "joi": "^17.12.3", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "nodemon": "^3.1.0" } } diff --git a/src/controllers/base.controller.spec.ts b/src/controllers/base.controller.spec.ts new file mode 100644 index 0000000..bcb928f --- /dev/null +++ b/src/controllers/base.controller.spec.ts @@ -0,0 +1,179 @@ +import { type Request, type Response } from 'express'; +import { HttpError } from '../middleware/errors.middleware'; +import { BaseController } from './baseController.js'; +import { type Repo } from '../repositories/baseRepo.js'; +import { type ObjectSchema } from 'joi'; + +type TestModel = Record; +type TestCreateDto = Record; +const testCreateDtoSchema = { + validate: jest.fn().mockReturnValue({ error: null, value: {} }), +} as unknown as ObjectSchema; +const testUpdateDtoSchema = { + validate: jest.fn().mockReturnValue({ error: null, value: {} }), +} as unknown as ObjectSchema; + +export class TestController extends BaseController { + constructor(protected readonly repo: Repo) { + super(repo, testCreateDtoSchema, testUpdateDtoSchema); + } +} + +describe('Given a instance of the class TestController', () => { + const repo = { + readAll: jest.fn(), + readById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as Repo; + + const req = {} as unknown as Request; + const res = { + json: jest.fn(), + status: jest.fn(), + } as unknown as Response; + const next = jest.fn(); + + const controller = new TestController(repo); + test('Then it should be instance of the class', () => { + expect(controller).toBeInstanceOf(TestController); + }); + + describe('When we use the method getAll', () => { + test('Then it should call repo.readAll', async () => { + (repo.readAll as jest.Mock).mockResolvedValue([]); + await controller.getAll(req, res, next); + expect(repo.readAll).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith([]); + }); + }); + + describe('When we use the method getAll and repo throw an ERROR', () => { + test('Then it should call repo.readAll and next', async () => { + const error = new Error('Something went wrong'); + (repo.readAll as jest.Mock).mockRejectedValue(error); + await controller.getAll(req, res, next); + expect(repo.readAll).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('When we use the method getById', () => { + test('Then it should call repo.readById', async () => { + (repo.readById as jest.Mock).mockResolvedValue({}); + req.params = { id: '1' }; + await controller.getById(req, res, next); + expect(repo.readById).toHaveBeenCalledWith('1'); + expect(res.json).toHaveBeenCalledWith({}); + }); + }); + + describe('When we use the method getById and repo throw an ERROR', () => { + test('Then it should call repo.readById and next', async () => { + const error = new Error('Something went wrong'); + (repo.readById as jest.Mock).mockRejectedValue(error); + req.params = { id: '1' }; + await controller.getById(req, res, next); + expect(repo.readById).toHaveBeenCalledWith('1'); + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('When we use the method create', () => { + test('Then it should call repo.create', async () => { + const article = { test: 'test' }; + req.body = article; + (repo.create as jest.Mock).mockResolvedValue(article); + await controller.create(req, res, next); + expect(repo.create).toHaveBeenCalledWith({}); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({}); + }); + }); + + describe('When we use the method create with INVALID data', () => { + test('Then it should call next with an error', async () => { + (testCreateDtoSchema.validate as jest.Mock).mockReturnValueOnce({ + error: new Error('error'), + value: {}, + }); + const article = { title: 'title' }; + req.body = article; + await controller.create(req, res, next); + expect(next).toHaveBeenCalledWith( + new HttpError(406, 'Not Acceptable', 'error') + ); + }); + }); + + describe('When we use the method create and repo throw an ERROR', () => { + test('Then it should call repo.create and next', async () => { + const error = new Error('Something went wrong'); + (repo.create as jest.Mock).mockRejectedValue(error); + const article = { title: 'title', author: 'autor' }; + req.body = article; + await controller.create(req, res, next); + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('When we use the method update', () => { + test('Then it should call repo.update', async () => { + const article = { title: 'title', authorId: 'test' }; + req.params = { id: '1' }; + req.body = article; + (repo.update as jest.Mock).mockResolvedValue(article); + await controller.update(req, res, next); + expect(repo.update).toHaveBeenCalledWith('1', article); + expect(res.json).toHaveBeenCalledWith(article); + }); + }); + + describe('When we use the method update with INVALID data', () => { + test('Then it should call next with an error', async () => { + (testUpdateDtoSchema.validate as jest.Mock).mockReturnValueOnce({ + error: new Error('error'), + value: {}, + }); + const article = { authorId: 34 }; + req.body = article; + await controller.update(req, res, next); + expect(next).toHaveBeenCalledWith( + new HttpError(406, 'Not Acceptable', 'error') + ); + }); + }); + + describe('When we use the method update and repo throw an ERROR', () => { + test('Then it should call repo.update and next', async () => { + const error = new Error('Something went wrong'); + (repo.update as jest.Mock).mockRejectedValue(error); + const article = { title: 'title', authorId: 'test' }; + req.body = article; + await controller.update(req, res, next); + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('When we use the method delete', () => { + test('Then it should call repo.delete', async () => { + req.params = { id: '1' }; + (repo.delete as jest.Mock).mockResolvedValue({}); + await controller.delete(req, res, next); + expect(repo.delete).toHaveBeenCalledWith('1'); + expect(res.json).toHaveBeenCalledWith({}); + }); + }); + + describe('When we use the method delete and repo throw an ERROR', () => { + test('Then it should call repo.delete and next', async () => { + const error = new Error('Something went wrong'); + (repo.delete as jest.Mock).mockRejectedValue(error); + req.params = { id: '1' }; + await controller.delete(req, res, next); + expect(repo.delete).toHaveBeenCalledWith('1'); + expect(next).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/src/controllers/baseController.ts b/src/controllers/baseController.ts new file mode 100644 index 0000000..8e4c724 --- /dev/null +++ b/src/controllers/baseController.ts @@ -0,0 +1,93 @@ +import { type NextFunction, type Request, type Response } from 'express'; +import createDebug from 'debug'; +import type Joi from 'joi'; +import { HttpError } from '../middleware/errors.middleware.js'; +import { type Repo } from '../repositories/baseRepo.js'; + +const debug = createDebug('W7E:base:controller'); + +export abstract class BaseController { + constructor( + protected readonly repo: Repo, + protected readonly validateCreateDtoSchema: Joi.ObjectSchema, + protected readonly validateUpdateDtoSchema: Joi.ObjectSchema> + ) { + debug('Instantiated base controller'); + } + + async getAll(req: Request, res: Response, next: NextFunction) { + try { + const result = await this.repo.readAll(); + res.json(result); + } catch (error) { + next(error); + } + } + + async getById(req: Request, res: Response, next: NextFunction) { + const { id } = req.params; + try { + const result = await this.repo.readById(id); + res.json(result); + } catch (error) { + next(error); + } + } + + async create(req: Request, res: Response, next: NextFunction) { + const data = req.body as C; + + const { + error, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + value, + }: { error: Error | undefined; value: C } = + this.validateCreateDtoSchema.validate(data, { + abortEarly: false, + }); + + if (error) { + next(new HttpError(406, 'Not Acceptable', error.message)); + return; + } + + try { + const result = await this.repo.create(value); + res.status(201); + res.json(result); + } catch (error) { + next(error); + } + } + + async update(req: Request, res: Response, next: NextFunction) { + const { id } = req.params; + const data = req.body as C; + + const { error } = this.validateUpdateDtoSchema.validate(data, { + abortEarly: false, + }); + + if (error) { + next(new HttpError(406, 'Not Acceptable', error.message)); + return; + } + + try { + const result = await this.repo.update(id, data); + res.json(result); + } catch (error) { + next(error); + } + } + + async delete(req: Request, res: Response, next: NextFunction) { + const { id } = req.params; + try { + const result = await this.repo.delete(id); + res.json(result); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/users. controller.spec.ts b/src/controllers/users. controller.spec.ts new file mode 100644 index 0000000..2989280 --- /dev/null +++ b/src/controllers/users. controller.spec.ts @@ -0,0 +1,166 @@ +import { type Request, type Response } from 'express'; +import { UsersController } from './users.controller'; +import { type UsersSqlRepo } from '../repositories/users.sql.repo'; +import { Auth } from '../services/auth.service.js'; +import { type ObjectSchema } from 'joi'; +import { type UserCreateDto } from '../entities/user'; + +jest.mock('../entities/user.schema.js', () => ({ + userCreateDtoSchema: { + validate: jest.fn().mockReturnValue({ error: null, value: {} }), + } as unknown as ObjectSchema, + userUpdateDtoSchema: { + validate: jest.fn().mockReturnValue({ error: null, value: {} }), + } as unknown as ObjectSchema, +})); + +describe('Given a instance of the class UsersController', () => { + const repo = { + readAll: jest.fn(), + readById: jest.fn(), + searchForLogin: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as UsersSqlRepo; + + const req = {} as unknown as Request; + const res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + } as unknown as Response; + const next = jest.fn(); + + Auth.hash = jest.fn().mockResolvedValue('hashedPassword'); + + const controller = new UsersController(repo); + test('Then it should be instance of the class', () => { + expect(controller).toBeInstanceOf(UsersController); + }); + + describe('When we use the method login', () => { + describe('And body is not valid', () => { + test('Then it should call next with an error', async () => { + req.body = {}; + await controller.login(req, res, next); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Email/name and password are required', + }) + ); + }); + }); + + describe('And user is not found', () => { + test('Then it should call next with an error', async () => { + req.body = { email: 'test@mail.com', password: 'password' }; + (repo.searchForLogin as jest.Mock).mockResolvedValue(null); + await controller.login(req, res, next); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Email/name and password invalid', + }) + ); + }); + }); + + describe('And password is invalid', () => { + test('Then it should call next with an error', async () => { + const user = { id: '1', password: 'password' }; + req.body = { email: 'test@mail.com', password: 'password' }; + (repo.searchForLogin as jest.Mock).mockResolvedValue(user); + Auth.compare = jest.fn().mockResolvedValue(false); + await controller.login(req, res, next); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Email/name and password invalid', + }) + ); + }); + }); + + describe('And all process is ok', () => { + test('Then it should call repo.searchForLogin and res methods', async () => { + const user = { id: '1', password: 'password' }; + req.body = { email: 'test@acme.com', password: 'password' }; + (repo.searchForLogin as jest.Mock).mockResolvedValue(user); + Auth.compare = jest.fn().mockResolvedValue(true); + Auth.signJwt = jest.fn().mockReturnValue('test'); + await controller.login(req, res, next); + expect(repo.searchForLogin).toHaveBeenCalledWith( + 'email', + 'test@acme.com' + ); + expect(Auth.compare).toHaveBeenCalledWith('password', 'password'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ token: 'test' }); + }); + test('Then it should call repo.searchForLogin and res methods', async () => { + const user = { id: '1', password: 'password' }; + req.body = { name: 'test', password: 'password' }; + (repo.searchForLogin as jest.Mock).mockResolvedValue(user); + Auth.compare = jest.fn().mockResolvedValue(true); + Auth.signJwt = jest.fn().mockReturnValue('test'); + await controller.login(req, res, next); + expect(repo.searchForLogin).toHaveBeenCalledWith('name', 'test'); + expect(Auth.compare).toHaveBeenCalledWith('password', 'password'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ token: 'test' }); + }); + }); + + describe('And an error is thrown', () => { + test('Then it should call next with an error', async () => { + req.body = { email: 'sample@mail.com', password: 'password' }; + (repo.searchForLogin as jest.Mock).mockRejectedValue(new Error()); + await controller.login(req, res, next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + }); + + describe('When we use the method create', () => { + describe('And body is not valid', () => { + test('Then it should call next with an error', async () => { + req.body = { name: 'test' }; + await controller.create(req, res, next); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Password is required and must be a string', + }) + ); + }); + }); + + describe('And body is ok', () => { + test('Then it should call repo.create', async () => { + const user = { name: 'test', password: 'test' }; + req.body = user; + req.body.cloudinary = { url: '' }; + req.body.avatar = req.body.cloudinary?.url as string; + Auth.hash = jest.fn().mockResolvedValue('hashedPassword'); + (repo.create as jest.Mock).mockResolvedValue(user); + await controller.create(req, res, next); + expect(Auth.hash).toHaveBeenCalledWith('test'); + expect(repo.create).toHaveBeenCalledWith({}); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(user); + }); + }); + }); + + describe('When we use the method update', () => { + test('Then it should call repo.update', async () => { + Auth.hash = jest.fn().mockResolvedValue('hashedPassword'); + const user = { id: '1', name: 'test', password: 'test' }; + const finalUser = { ...user, password: 'hashedPassword' }; + req.params = { id: '1' }; + req.body = { ...user, id: req.params.id }; + (repo.update as jest.Mock).mockResolvedValue(finalUser); + await controller.update(req, res, next); + expect(Auth.hash).toHaveBeenCalledWith('test'); + expect(repo.update).toHaveBeenCalledWith('1', finalUser); + expect(res.json).toHaveBeenCalledWith(finalUser); + }); + }); +}); diff --git a/src/controllers/users.controller.ts b/src/controllers/users.controller.ts new file mode 100644 index 0000000..f5096c4 --- /dev/null +++ b/src/controllers/users.controller.ts @@ -0,0 +1,95 @@ +import { type NextFunction, type Request, type Response } from 'express'; +import createDebug from 'debug'; +import { type UserCreateDto, type User } from '../entities/user'; +import { + userCreateDtoSchema, + userUpdateDtoSchema, +} from '../entities/user.schema.js'; +import { type WithLoginRepo } from '../repositories/baseRepo.js'; +import { BaseController } from './baseController.js'; +import { HttpError } from '../middleware/errors.middleware.js'; +import { Auth } from '../services/auth.service.js'; + +const debug = createDebug('BOOKS:users:controller'); + +export class UsersController extends BaseController { + constructor(protected readonly repo: WithLoginRepo) { + super(repo, userCreateDtoSchema, userUpdateDtoSchema); + + debug('Instantiated users controller'); + } + + async login(req: Request, res: Response, next: NextFunction) { + const { email, name, password } = req.body as UserCreateDto; + + if ((!email && !name) || !password) { + next( + new HttpError( + 400, + 'Bad Request', + 'Email/name and password are required' + ) + ); + return; + } + + const error = new HttpError( + 401, + 'Unauthorized', + 'Email/name and password invalid' + ); + + try { + const user = await this.repo.searchForLogin( + email ? 'email' : 'name', + email || name + ); + + if (!user) { + next(error); + return; + } + + if (!(await Auth.compare(password, user.password!))) { + next(error); + return; + } + + const token = Auth.signJwt({ + id: user.id!, + role: user.role!, + }); + + res.status(200).json({ token }); + } catch (error) { + next(error); + } + } + + async create(req: Request, res: Response, next: NextFunction) { + if (!req.body.password || typeof req.body.password !== 'string') { + next( + new HttpError( + 400, + 'Bad Request', + 'Password is required and must be a string' + ) + ); + return; + } + + req.body.password = await Auth.hash(req.body.password as string); + + req.body.avatar = req.body.cloudinary?.url as string; + + await super.create(req, res, next); + } + + async update(req: Request, res: Response, next: NextFunction) { + if (req.body.password && typeof req.body.password === 'string') { + req.body.password = await Auth.hash(req.body.password as string); + } + + await super.update(req, res, next); + } +} diff --git a/src/entities/user.schema.ts b/src/entities/user.schema.ts index 7c14335..7e2eb22 100644 --- a/src/entities/user.schema.ts +++ b/src/entities/user.schema.ts @@ -1,5 +1,5 @@ import Joi from 'joi'; -import { type UserCreateDto, type UserUpdateDto } from './user'; +import { type UserCreateDto, type UserUpdateDto } from './user.js'; export const userCreateDtoSchema = Joi.object({ name: Joi.string().required(), @@ -7,7 +7,6 @@ export const userCreateDtoSchema = Joi.object({ password: Joi.string().required(), repeatPassword: Joi.string().required(), avatar: Joi.string().allow('', null), - role: Joi.string().valid('admin', 'user').required(), }); @@ -17,6 +16,5 @@ export const userUpdateDtoSchema = Joi.object({ password: Joi.string(), repeatPassword: Joi.string(), avatar: Joi.string().allow('', null), - role: Joi.string().valid('admin', 'user'), }); diff --git a/src/middleware/files.interceptor.spec.ts b/src/middleware/files.interceptor.spec.ts new file mode 100644 index 0000000..54dfbe7 --- /dev/null +++ b/src/middleware/files.interceptor.spec.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { type Request, type Response } from 'express'; +import { FilesInterceptor } from './files.interceptor.js'; +import multer, { type MulterError } from 'multer'; +import { v2 } from 'cloudinary'; +import { HttpError } from './errors.middleware.js'; + +jest.mock('multer'); +jest.mock('cloudinary'); + +describe('Given a instance of the class FilesInterceptor', () => { + const interceptor = new FilesInterceptor(); + const req = { + body: {}, + file: {}, + } as unknown as Request; + const res = {} as unknown as Response; + const next = jest.fn(); + test('Then it should be instance of the class', () => { + expect(interceptor).toBeInstanceOf(FilesInterceptor); + }); + describe('When we use the method singleFile', () => { + const mockMiddleware = jest.fn(); + + multer.diskStorage = jest.fn().mockImplementation(({ filename }) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + filename('', '', () => { + // + }) + ); + + (multer as unknown as jest.Mock).mockReturnValue({ + single: jest.fn().mockReturnValue(mockMiddleware), + }); + + test('Then it should call Multer middleware', () => { + interceptor.singleFile()(req, res, next); + expect(mockMiddleware).toHaveBeenCalled(); + }); + }); + + describe('When we use the method cloudinaryUpload', () => { + v2.uploader = { + upload: jest.fn().mockResolvedValue({}), + } as unknown as typeof v2.uploader; + + describe('And file is valid', () => { + test('Then it should call next', async () => { + req.file = {} as unknown as Express.Multer.File; + await interceptor.upload(req, res, next); + expect(v2.uploader.upload).toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + }); + describe('And file is not valid', () => { + test('Then it should call next', async () => { + req.file = undefined; + await interceptor.upload(req, res, next); + expect(v2.uploader.upload).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith( + new HttpError(400, 'Bad Request', 'No file uploaded') + ); + }); + }); + }); +}); diff --git a/src/middleware/files.interceptor.ts b/src/middleware/files.interceptor.ts new file mode 100644 index 0000000..cfb6cd0 --- /dev/null +++ b/src/middleware/files.interceptor.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { type NextFunction, type Request, type Response } from 'express'; +import createDebug from 'debug'; + +import { v2 as cloudinary } from 'cloudinary'; +import { HttpError } from './errors.middleware.js'; +import multer from 'multer'; + +const debug = createDebug('BOOKS:files:interceptor'); + +export class FilesInterceptor { + constructor() { + debug('Instantiated files interceptor'); + } + + singleFile(fieldName = 'avatar') { + const storage = multer.diskStorage({ + destination: 'uploads/', + filename( + _req: Request, + file: Express.Multer.File, + callback: (error: Error, filename: string) => void + ) { + callback(new Error(), Date.now() + '_' + file.originalname); + }, + }); + + const upload = multer({ storage }); + const middleware = upload.single(fieldName); + + return (req: Request, res: Response, next: NextFunction) => { + const previousBody = { ...req.body }; + middleware(req, res, (err: any) => { + if (err instanceof multer.MulterError) { + next(new HttpError(400, 'Bad Request', err.message)); + return; + } + + if (err) { + next( + new HttpError(500, 'Internal Server Error', 'File upload error') + ); + return; + } + + req.body = { ...previousBody, ...req.body }; + next(); + }); + }; + } + + async upload(req: Request, res: Response, next: NextFunction) { + const options = { + useFilename: true, + uniqueFilename: false, + overwrite: true, + }; + if (!req.file) { + next(new HttpError(400, 'Bad Request', 'No file uploaded')); + return; + } + + try { + const result = await cloudinary.uploader.upload(req.file.path, options); + req.body.cloudinary = result; + next(); + } catch (error) { + next(new HttpError(500, 'Internal Server Error', 'Error uploading file')); + } + } +} diff --git a/src/repositories/users.sql.repo.spec.ts b/src/repositories/users.sql.repo.spec.ts index 6f97c91..ee0c8ba 100644 --- a/src/repositories/users.sql.repo.spec.ts +++ b/src/repositories/users.sql.repo.spec.ts @@ -115,4 +115,19 @@ describe('Given a instance of the class UsersSqlRepo', () => { ); }); }); + describe('When we use the method create', () => { + test('Then it should call prisma.create', async () => { + (mockPrisma.user.create as jest.Mock).mockRejectedValueOnce( + new Error('Failed to create user') + ); + + const data = {} as unknown as UserCreateDto; + + await expect(repo.create(data)).rejects.toThrow( + new HttpError(500, 'Internal Server Error', 'Failed to create user') + ); + + expect(mockPrisma.user.create).toHaveBeenCalled(); + }); + }); }); diff --git a/src/services/auth.service.spec.ts b/src/services/auth.service.spec.ts new file mode 100644 index 0000000..840c723 --- /dev/null +++ b/src/services/auth.service.spec.ts @@ -0,0 +1,57 @@ +import { hash, compare } from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { Auth } from './auth.service.js'; + +jest.mock('bcrypt'); +jest.mock('jsonwebtoken'); +describe('Given the "static" class Auth', () => { + describe('When we use the static method hash', () => { + test('Then it should call hash from bcrypt', async () => { + await Auth.hash('test'); + expect(hash).toHaveBeenCalled(); + }); + }); + + describe('When we use the static method compare', () => { + test('Then it should call compare from bcrypt', async () => { + await Auth.compare('test', 'test'); + expect(compare).toHaveBeenCalled(); + }); + }); + + describe('When we use the static method signJwt', () => { + test('Then it should call sign from jwt', () => { + Auth.secret = 'test secret'; + Auth.signJwt({ id: 'test', role: 'test' }); + expect(jwt.sign).toHaveBeenCalled(); + }); + }); + + describe('When we use the static method signJwt', () => { + describe('And there are not secret in process.env', () => { + test('Then it should call sign from jwt', () => { + Auth.secret = ''; + const result = () => Auth.signJwt({ id: 'test', role: 'test' }); + expect(result).toThrow(); + }); + }); + }); + + describe('When we use the static method verifyJwt', () => { + test('Then it should call verify from jwt', () => { + Auth.secret = 'test secret'; + Auth.verifyJwt('test'); + expect(jwt.verify).toHaveBeenCalled(); + }); + }); + + describe('When we use the static method verifyJwt', () => { + describe('And there are not secret in process.env', () => { + test('Then it should call verify from jwt', () => { + Auth.secret = ''; + const result = () => Auth.verifyJwt('test'); + expect(result).toThrow(); + }); + }); + }); +}); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..a0ca81c --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class */ +import { hash, compare } from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +export type Payload = { + id: string; + role: string; +} & jwt.JwtPayload; + +export class Auth { + static secret = process.env.SECRET_JWT; + static async hash(value: string) { + return hash(value, 10); + } + + static async compare(value: string, hash: string) { + return compare(value, hash); + } + + static signJwt(payload: Payload) { + if (!Auth.secret) throw new Error('JWT secret not set'); + return jwt.sign(payload, Auth.secret); + } + + static verifyJwt(token: string) { + if (!Auth.secret) throw new Error('JWT secret not set'); + return jwt.verify(token, Auth.secret) as Payload; + } +} diff --git a/src/tools/db.connect.ts b/src/tools/db.connect.ts new file mode 100644 index 0000000..6a7aa6f --- /dev/null +++ b/src/tools/db.connect.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client'; +import createDebug from 'debug'; + +const debug = createDebug('BOOKS:db.connect'); +export const dbConnect = async () => { + debug('Connecting to database'); + const prisma = new PrismaClient(); + return prisma; +};