diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..641ca76 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#65c89b", + "activityBar.background": "#65c89b", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#945bc4", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#15202b99", + "sash.hoverBorder": "#65c89b", + "statusBar.background": "#42b883", + "statusBar.foreground": "#15202b", + "statusBarItem.hoverBackground": "#359268", + "statusBarItem.remoteBackground": "#42b883", + "statusBarItem.remoteForeground": "#15202b", + "titleBar.activeBackground": "#42b883", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#42b88399", + "titleBar.inactiveForeground": "#15202b99" + }, + "peacock.color": "#42b883" +} diff --git a/package-lock.json b/package-lock.json index c231994..e7843dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@prisma/client": "^5.12.1", "bcrypt": "^5.1.1", + "cloudinary": "^2.2.0", "cors": "^2.8.5", "cross-env": "^7.0.3", "debug": "^4.3.4", @@ -20,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", "prisma": "^5.13.0" }, @@ -32,6 +34,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", @@ -1566,6 +1569,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", @@ -1958,6 +1970,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", @@ -2273,8 +2290,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", @@ -2442,6 +2469,18 @@ "node": ">=12" } }, + "node_modules/cloudinary": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.2.0.tgz", + "integrity": "sha512-akbLTZcNegGSkl07Frnt9fyiK9KZ2zPS+a+j7uLrjNYxVhDpDdIBz9G6snPCYqgk+WLVMRPfXTObalLr5L6g0Q==", + "dependencies": { + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2489,6 +2528,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", @@ -2538,6 +2618,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", @@ -3884,6 +3969,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", @@ -4713,6 +4803,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -4909,6 +5004,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", @@ -5000,6 +5103,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/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5498,6 +5629,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", @@ -5553,6 +5689,15 @@ } ] }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -6007,6 +6152,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", @@ -6333,6 +6486,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", @@ -6528,6 +6686,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 4416da7..fd5971b 100644 --- a/package.json +++ b/package.json @@ -23,14 +23,15 @@ "singleQuote": true }, "devDependencies": { + "@jest/reporters": "29.7.0", "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/debug": "^4.1.12", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", - "@jest/reporters": "29.7.0", "@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", @@ -46,6 +47,7 @@ "dependencies": { "@prisma/client": "^5.12.1", "bcrypt": "^5.1.1", + "cloudinary": "^2.2.0", "cors": "^2.8.5", "cross-env": "^7.0.3", "debug": "^4.3.4", @@ -55,6 +57,7 @@ "joi": "^17.12.3", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "nodemon": "^3.1.0", "prisma": "^5.13.0" } diff --git a/src/app.ts b/src/app.ts index e9615a9..3e3ad7a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,10 @@ import { UsersSqlRepo } from './repositories/users.sql.repo.js'; import { UsersController } from './controller/user.controller.js'; import { UsersRouter } from './router/users.router.js'; import { ErrorsMiddleware } from './middleware/errors.middleware.js'; +import { CharacterSqlRepo } from './repositories/characters.sql.repo.js'; +import { CharacterController } from './controller/characters.controller.js'; +import { CharacterRouter } from './router/characters.router.js'; +import { FilesInterceptor } from './middleware/files.interceptor.js'; const debug = createDebug('GONJI:app'); export const createApp = () => { @@ -22,11 +26,23 @@ export const startApp = (app: Express, prisma: PrismaClient) => { app.use(cors()); const authInterceptor = new AuthInterceptor(); + const filesInterceptor = new FilesInterceptor(); + const userRepo = new UsersSqlRepo(prisma); const userController = new UsersController(userRepo); const userRouter = new UsersRouter(userController, authInterceptor); app.use('/users', userRouter.router); + const characterRepo = new CharacterSqlRepo(prisma); + const characterController = new CharacterController(characterRepo); + const characterRouter = new CharacterRouter( + characterController, + authInterceptor, + characterRepo, + filesInterceptor + ); + app.use('/character', characterRouter.router); + const errorsMiddleware = new ErrorsMiddleware(); app.use(errorsMiddleware.handle.bind(errorsMiddleware)); }; diff --git a/src/controller/base.controller.test.ts b/src/controller/base.controller.test.ts index 15898ac..f2b340d 100644 --- a/src/controller/base.controller.test.ts +++ b/src/controller/base.controller.test.ts @@ -82,9 +82,9 @@ describe('Given a instance of the class TestController', () => { 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); + const character = { test: 'test' }; + req.body = character; + (repo.create as jest.Mock).mockResolvedValue(character); await controller.create(req, res, next); expect(repo.create).toHaveBeenCalledWith({}); expect(res.status).toHaveBeenCalledWith(201); @@ -98,8 +98,8 @@ describe('Given a instance of the class TestController', () => { error: new Error('error'), value: {}, }); - const article = { title: 'title' }; - req.body = article; + const character = { title: 'title' }; + req.body = character; await controller.create(req, res, next); expect(next).toHaveBeenCalledWith( new HttpError(406, 'Not Acceptable', 'error') @@ -111,8 +111,8 @@ describe('Given a instance of the class TestController', () => { 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; + const character = { title: 'title', author: 'autor' }; + req.body = character; await controller.create(req, res, next); expect(next).toHaveBeenCalledWith(error); }); @@ -120,13 +120,13 @@ describe('Given a instance of the class TestController', () => { describe('When we use the method update', () => { test('Then it should call repo.update', async () => { - const article = { title: 'title', authorId: 'test' }; + const character = { title: 'title', authorId: 'test' }; req.params = { id: '1' }; - req.body = article; - (repo.update as jest.Mock).mockResolvedValue(article); + req.body = character; + (repo.update as jest.Mock).mockResolvedValue(character); await controller.update(req, res, next); - expect(repo.update).toHaveBeenCalledWith('1', article); - expect(res.json).toHaveBeenCalledWith(article); + expect(repo.update).toHaveBeenCalledWith('1', character); + expect(res.json).toHaveBeenCalledWith(character); }); }); @@ -136,8 +136,8 @@ describe('Given a instance of the class TestController', () => { error: new Error('error'), value: {}, }); - const article = { authorId: 34 }; - req.body = article; + const character = { authorId: 34 }; + req.body = character; await controller.update(req, res, next); expect(next).toHaveBeenCalledWith( new HttpError(406, 'Not Acceptable', 'error') @@ -149,8 +149,8 @@ describe('Given a instance of the class TestController', () => { 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; + const character = { title: 'title', authorId: 'test' }; + req.body = character; await controller.update(req, res, next); expect(next).toHaveBeenCalledWith(error); }); diff --git a/src/controller/base.controller.ts b/src/controller/base.controller.ts index 55e999d..cc4365d 100644 --- a/src/controller/base.controller.ts +++ b/src/controller/base.controller.ts @@ -10,7 +10,7 @@ export abstract class BaseController { constructor( protected readonly repo: Repo, protected readonly validateCreateDtoSchema: Joi.ObjectSchema, - protected readonly validateUpdateDtoSchema: Joi.ObjectSchema + protected readonly validateUpdateDtoSchema: Joi.ObjectSchema> ) { debug('Instantiated base controller'); } diff --git a/src/controller/characters.controller.test.ts b/src/controller/characters.controller.test.ts new file mode 100644 index 0000000..90b6796 --- /dev/null +++ b/src/controller/characters.controller.test.ts @@ -0,0 +1,24 @@ +import { type Request, type Response } from 'express'; +import { type CharacterSqlRepo } from '../repositories/characters.sql.repo'; +import { CharacterController } from './characters.controller'; + +describe('Given a instance of the class CharacterController', () => { + const repo = { + create: jest.fn(), + readById: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as CharacterSqlRepo; + + 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 CharacterController(repo); + test('Then it should be instance of the class', () => { + expect(controller).toBeInstanceOf(CharacterController); + }); +}); diff --git a/src/controller/characters.controller.ts b/src/controller/characters.controller.ts new file mode 100644 index 0000000..5232d13 --- /dev/null +++ b/src/controller/characters.controller.ts @@ -0,0 +1,39 @@ +import { type NextFunction, type Request, type Response } from 'express'; +import createDebug from 'debug'; + +import { type Repo } from '../repositories/type.repo'; +import { BaseController } from './base.controller.js'; +import { + type Character, + type CharacterCreateDto, +} from '../entities/character.js'; +import { + characterCreateSchema, + characterUpdateSchema, +} from '../entities/character.schema.js'; +import { type Payload } from '../services/auth.services'; + +const debug = createDebug('GONJI:character:controller'); + +export class CharacterController extends BaseController< + Character, + CharacterCreateDto +> { + constructor(protected readonly repo: Repo) { + super(repo, characterCreateSchema, characterUpdateSchema); + + debug('Instantiated character controller'); + } + + async create(req: Request, res: Response, next: NextFunction) { + debug('Creating character'); + req.body.userId = (req.body.payload as Payload).id; + + const { payload, ...rest } = req.body as CharacterCreateDto & { + payload: Payload; + }; + req.body = rest; + + await super.create(req, res, next); + } +} diff --git a/src/entities/character.schema.ts b/src/entities/character.schema.ts index aeccb65..e57d077 100644 --- a/src/entities/character.schema.ts +++ b/src/entities/character.schema.ts @@ -7,6 +7,7 @@ export const characterCreateSchema = joy.object({ description: joy.string().required(), faction: joy.string().required(), userId: joy.string().required(), + race: joy.string().required(), }); export const characterUpdateSchema = joy.object>({ name: joy.string(), @@ -14,4 +15,5 @@ export const characterUpdateSchema = joy.object>({ description: joy.string(), faction: joy.string(), userId: joy.string(), + race: joy.string(), }); diff --git a/src/entities/character.ts b/src/entities/character.ts index 41ca820..5586871 100644 --- a/src/entities/character.ts +++ b/src/entities/character.ts @@ -4,7 +4,7 @@ export type Character = { imgUrl: string; description: string; faction: string; - race: 'men' | 'elf' | 'elve' | 'dwarf' | 'urukhai' | 'orc' | 'hobbit'; + race: 'men' | 'elve' | 'dwarf' | 'urukhai' | 'orc' | 'hobbit'; userId: string; }; export type CharacterCreateDto = { @@ -12,7 +12,7 @@ export type CharacterCreateDto = { imgUrl: string; description: string; faction: string; - race: 'men' | 'elf' | 'elve' | 'dwarf' | 'urukhai' | 'orc' | 'hobbit'; + race: 'men' | 'elve' | 'dwarf' | 'urukhai' | 'orc' | 'hobbit'; userId: string; }; export type CharacterUpdateDto = Partial; diff --git a/src/middleware/auth.interceptor.test.ts b/src/middleware/auth.interceptor.test.ts index cd8fd9a..6c87be9 100644 --- a/src/middleware/auth.interceptor.test.ts +++ b/src/middleware/auth.interceptor.test.ts @@ -47,4 +47,41 @@ describe('Given a instance of the class AuthInterceptor', () => { }); }); }); + + describe('When we use the method authorization', () => { + const req = { + body: { payload: {} }, + params: { id: '123' }, + } as unknown as Request; + const res = {} as unknown as Response; + const next = jest.fn(); + + type T = { id: string }; + + const repo: Repo = { + readById: jest.fn().mockResolvedValue({ id: '123' }), + } as unknown as Repo; + + test('Then it should call next', async () => { + await interceptor.authorization(repo)(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + describe('And method have a second parameter', () => { + test('Then it should call next', async () => { + req.body = { payload: { id: '123' } }; + await interceptor.authorization(repo, 'id')(req, res, next); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('And fail repo readById', () => { + test('Then it should call next with error', async () => { + req.body = { payload: { id: '123' } }; + repo.readById = jest.fn().mockRejectedValue(new Error('Error')); + await interceptor.authorization(repo)(req, res, next); + expect(next).toHaveBeenCalledWith(new Error('Error')); + }); + }); + }); }); diff --git a/src/middleware/auth.interceptor.ts b/src/middleware/auth.interceptor.ts index b4cddec..9e37402 100644 --- a/src/middleware/auth.interceptor.ts +++ b/src/middleware/auth.interceptor.ts @@ -12,7 +12,6 @@ export class AuthInterceptor { authentication(req: Request, _res: Response, next: NextFunction) { debug('Authenticating'); - const data = req.get('Authorization'); const error = new HttpError(498, ' Token expired/invalid', 'Token invalid'); @@ -31,4 +30,38 @@ export class AuthInterceptor { next(error); } } + + authorization( + repo: Repo>, + ownerKey?: keyof T + ) { + return async (req: Request, res: Response, next: NextFunction) => { + debug('Authorizing'); + + const { payload, ...rest } = req.body as { payload: Payload }; + req.body = rest; + + try { + const item: T = await repo.readById(req.params.id); + + const ownerId = ownerKey ? item[ownerKey] : item.id; + + if (payload.id !== ownerId) { + next( + new HttpError( + 403, + 'Forbidden', + 'You are not allowed to access this resource' + ) + ); + return; + } + + debug('Authorized', req.body); + next(); + } catch (error) { + next(error); + } + }; + } } diff --git a/src/middleware/errors.middleware.test.ts b/src/middleware/errors.middleware.test.ts index 769a6ad..f8d22d3 100644 --- a/src/middleware/errors.middleware.test.ts +++ b/src/middleware/errors.middleware.test.ts @@ -16,7 +16,7 @@ describe('Given a instance of the class ErrorsMiddleware', () => { }); describe('When we use the method handle with a HttpError', () => { test('Then it should call res.status 404', () => { - const error = new HttpError(404, 'Not Found', 'Article not found'); + const error = new HttpError(404, 'Not Found', 'character not found'); middleware.handle(error, req, res, next); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalled(); diff --git a/src/middleware/files.interceptor.test.ts b/src/middleware/files.interceptor.test.ts new file mode 100644 index 0000000..09fe08f --- /dev/null +++ b/src/middleware/files.interceptor.test.ts @@ -0,0 +1,80 @@ +import { type Request, type Response } from 'express'; +import { FilesInterceptor } from './files.interceptor'; +import multer from 'multer'; +import { v2 } from 'cloudinary'; + +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 max-nested-callbacks, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + 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 not valid', () => { + test('Then it should call next with an error', async () => { + req.file = undefined; + await interceptor.cloudinaryUpload(req, res, next); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'No file uploaded', + }) + ); + }); + }); + + describe('And file is valid', () => { + test('Then it should call next', async () => { + req.file = {} as unknown as Express.Multer.File; + await interceptor.cloudinaryUpload(req, res, next); + expect(v2.uploader.upload).toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('And upload fails', () => { + test('Then it should call next with an error', async () => { + req.file = {} as unknown as Express.Multer.File; + const error = new Error('Upload failed'); + v2.uploader.upload = jest.fn().mockRejectedValue(error); + await interceptor.cloudinaryUpload(req, res, next); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: error.message, + }) + ); + }); + }); + }); +}); diff --git a/src/middleware/files.interceptor.ts b/src/middleware/files.interceptor.ts new file mode 100644 index 0000000..5af0169 --- /dev/null +++ b/src/middleware/files.interceptor.ts @@ -0,0 +1,60 @@ +import { type NextFunction, type Request, type Response } from 'express'; +import { v2 } from 'cloudinary'; +import createDebug from 'debug'; +import multer from 'multer'; +import { HttpError } from './errors.middleware.js'; +const debug = createDebug('GONJI:files:interceptor'); + +export class FilesInterceptor { + constructor() { + debug('Instantiated files interceptor'); + } + + singleFile(fieldName = 'imgUrl') { + debug('Creating single file middleware'); + const storage = multer.diskStorage({ + destination: 'uploads/', + filename(_req, file, callback) { + callback(null, Date.now() + '_' + file.originalname); + }, + }); + + const upload = multer({ storage }); + const middleware = upload.single(fieldName); + + return (req: Request, res: Response, next: NextFunction) => { + debug('Uploading single file'); + const previousBoy = req.body as Record; + middleware(req, res, next); + req.body = { ...previousBoy, ...req.body } as unknown; + }; + } + + async cloudinaryUpload(req: Request, res: Response, next: NextFunction) { + debug('Uploading file to cloudinary'); + const options = { + folder: 'bc2024_1', + // eslint-disable-next-line @typescript-eslint/naming-convention + use_filename: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + unique_filename: false, + overwrite: true, + }; + + if (!req.file) { + next(new HttpError(400, 'Bad request', 'No file uploaded')); + return; + } + + const finalPath = req.file.destination + '/' + req.file.filename; + try { + const result = await v2.uploader.upload(finalPath, options); + req.body.imgUrl = result.secure_url; + next(); + } catch (error) { + next( + new HttpError(500, 'Internal server error', (error as Error).message) + ); + } + } +} diff --git a/src/repositories/characters.sql.repo.test.ts b/src/repositories/characters.sql.repo.test.ts new file mode 100644 index 0000000..0fba786 --- /dev/null +++ b/src/repositories/characters.sql.repo.test.ts @@ -0,0 +1,96 @@ +import { type PrismaClient } from '@prisma/client'; +import { HttpError } from '../middleware/errors.middleware'; +import { CharacterSqlRepo } from './characters.sql.repo'; +import { type CharacterCreateDto } from '../entities/character'; + +const mockPrisma = { + character: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ id: '1' }), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + delete: jest.fn().mockResolvedValue({}), + }, +} as unknown as PrismaClient; + +describe('Given a instance of the class CharacterSqlRepo', () => { + const repo = new CharacterSqlRepo(mockPrisma); + + test('Then it should be instance of the class', () => { + expect(repo).toBeInstanceOf(CharacterSqlRepo); + }); + + describe('When we use the method readAll', () => { + test('Then it should call prisma.findMany', async () => { + const result = await repo.readAll(); + expect(mockPrisma.character.findMany).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + }); + + describe('When we use the method readById with a valid ID', () => { + test('Then it should call prisma.findUnique', async () => { + const result = await repo.readById('1'); + expect(mockPrisma.character.findUnique).toHaveBeenCalled(); + expect(result).toEqual({ id: '1' }); + }); + }); + + describe('When we use the method readById with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.character.findUnique as jest.Mock).mockResolvedValueOnce( + null + ); + await expect(repo.readById('2')).rejects.toThrow( + new HttpError(404, 'Not Found', 'character 2 not found') + ); + }); + }); + + describe('When we use the method create', () => { + test('Then it should call prisma.create', async () => { + const data = {} as unknown as CharacterCreateDto; + const result = await repo.create(data); + expect(mockPrisma.character.create).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe('When we use the method update with a valid ID', () => { + test('Then it should call prisma.update', async () => { + const result = await repo.update('1', {}); + expect(mockPrisma.character.update).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe('When we use the method update with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.character.findUnique as jest.Mock).mockResolvedValueOnce( + null + ); + await expect(repo.update('2', {})).rejects.toThrow( + new HttpError(404, 'Not Found', 'character 2 not found') + ); + }); + }); + + describe('When we use the method delete with a valid ID', () => { + test('Then it should call prisma.delete', async () => { + const result = await repo.delete('1'); + expect(mockPrisma.character.delete).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe('When we use the method delete with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.character.findUnique as jest.Mock).mockResolvedValueOnce( + null + ); + await expect(repo.delete('2')).rejects.toThrow( + new HttpError(404, 'Not Found', 'character 2 not found') + ); + }); + }); +}); diff --git a/src/repositories/characters.sql.repo.ts b/src/repositories/characters.sql.repo.ts new file mode 100644 index 0000000..669ef34 --- /dev/null +++ b/src/repositories/characters.sql.repo.ts @@ -0,0 +1,85 @@ +import { type PrismaClient } from '@prisma/client'; +import createDebug from 'debug'; +import { HttpError } from '../middleware/errors.middleware.js'; +import { type Repo } from './type.repo.js'; +import { + type Character, + type CharacterCreateDto, +} from '../entities/character.js'; +const debug = createDebug('GONJI:characters:repository:sql'); + +const select = { + id: true, + name: true, + imgUrl: true, + description: true, + faction: true, + race: true, + userId: true, +}; + +export class CharacterSqlRepo implements Repo { + constructor(private readonly prisma: PrismaClient) { + debug('Instantiated characters sql repository'); + } + + async readAll() { + const characters = await this.prisma.character.findMany({ + select, + }); + return characters; + } + + async readById(id: string) { + const character = await this.prisma.character.findUnique({ + where: { id }, + select, + }); + + if (!character) { + throw new HttpError(404, 'Not Found', `character ${id} not found`); + } + + return character; + } + + async create(data: CharacterCreateDto) { + return this.prisma.character.create({ + data: { + ...data, + }, + select, + }); + } + + async update(id: string, data: Partial) { + const character = await this.prisma.character.findUnique({ + where: { id }, + select, + }); + if (!character) { + throw new HttpError(404, 'Not Found', `character ${id} not found`); + } + + return this.prisma.character.update({ + where: { id }, + data, + select, + }); + } + + async delete(id: string) { + const character = await this.prisma.character.findUnique({ + where: { id }, + select, + }); + if (!character) { + throw new HttpError(404, 'Not Found', `character ${id} not found`); + } + + return this.prisma.character.delete({ + where: { id }, + select, + }); + } +} diff --git a/src/router/characters.router.test.ts b/src/router/characters.router.test.ts new file mode 100644 index 0000000..90dcee7 --- /dev/null +++ b/src/router/characters.router.test.ts @@ -0,0 +1,38 @@ +import { type CharacterController } from '../controller/characters.controller'; +import { type AuthInterceptor } from '../middleware/auth.interceptor'; +import { type FilesInterceptor } from '../middleware/files.interceptor'; +import { type CharacterSqlRepo } from '../repositories/characters.sql.repo'; +import { CharacterRouter } from './characters.router'; + +describe('Given a instance of the class CharacterRouter', () => { + const controller = { + getAll: jest.fn(), + getById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as CharacterController; + + const authInterceptor = { + authentication: jest.fn(), + authorization: jest.fn().mockReturnValue(jest.fn()), + } as unknown as AuthInterceptor; + + const characterSqlRepo = {} as unknown as CharacterSqlRepo; + + const filesInterceptor = { + singleFile: jest.fn().mockReturnValue(jest.fn()), + cloudinaryUpload: jest.fn(), + } as unknown as FilesInterceptor; + + const router = new CharacterRouter( + controller, + authInterceptor, + characterSqlRepo, + filesInterceptor + ); + + test('Then it should be instance of the class', () => { + expect(router).toBeInstanceOf(CharacterRouter); + }); +}); diff --git a/src/router/characters.router.ts b/src/router/characters.router.ts new file mode 100644 index 0000000..39ee37d --- /dev/null +++ b/src/router/characters.router.ts @@ -0,0 +1,56 @@ +import { Router as createRouter } from 'express'; +import createDebug from 'debug'; +import { type AuthInterceptor } from '../middleware/auth.interceptor'; +import { type CharacterController } from '../controller/characters.controller'; +import { type CharacterSqlRepo } from '../repositories/characters.sql.repo'; +import { type FilesInterceptor } from '../middleware/files.interceptor'; + +const debug = createDebug('GONJI:characters:router'); + +export class CharacterRouter { + router = createRouter(); + + constructor( + readonly controller: CharacterController, + readonly authInterceptor: AuthInterceptor, + readonly characterSqlRepo: CharacterSqlRepo, + readonly filesInterceptor: FilesInterceptor + ) { + debug('Instantiated character router'); + + this.router.get( + '/', + authInterceptor.authentication.bind(authInterceptor), + controller.getAll.bind(controller) + ); + this.router.get( + '/:id', + authInterceptor.authentication.bind(authInterceptor), + controller.getById.bind(controller) + ); + this.router.post( + '/', + authInterceptor.authentication.bind(authInterceptor), + filesInterceptor.singleFile('imgUrl'), + filesInterceptor.cloudinaryUpload.bind(filesInterceptor), + + controller.create.bind(controller) + ); + this.router.patch( + '/:id', + authInterceptor.authentication.bind(authInterceptor), + authInterceptor + .authorization(characterSqlRepo, 'userId') + .bind(authInterceptor), + controller.update.bind(controller) + ); + this.router.delete( + '/:id', + authInterceptor.authentication.bind(authInterceptor), + authInterceptor + .authorization(characterSqlRepo, 'userId') + .bind(authInterceptor), + controller.delete.bind(controller) + ); + } +} diff --git a/uploads/1715615831986_5fa531db62bbf.png b/uploads/1715615831986_5fa531db62bbf.png new file mode 100644 index 0000000..862b55f Binary files /dev/null and b/uploads/1715615831986_5fa531db62bbf.png differ diff --git a/uploads/1715615997908_5fa531db62bbf.png b/uploads/1715615997908_5fa531db62bbf.png new file mode 100644 index 0000000..862b55f Binary files /dev/null and b/uploads/1715615997908_5fa531db62bbf.png differ diff --git a/uploads/1715616285854_5fa531db62bbf.png b/uploads/1715616285854_5fa531db62bbf.png new file mode 100644 index 0000000..862b55f Binary files /dev/null and b/uploads/1715616285854_5fa531db62bbf.png differ diff --git a/uploads/1715616371548_5fa531db62bbf.png b/uploads/1715616371548_5fa531db62bbf.png new file mode 100644 index 0000000..862b55f Binary files /dev/null and b/uploads/1715616371548_5fa531db62bbf.png differ diff --git a/uploads/1715616397394_5fa531db62bbf.png b/uploads/1715616397394_5fa531db62bbf.png new file mode 100644 index 0000000..862b55f Binary files /dev/null and b/uploads/1715616397394_5fa531db62bbf.png differ diff --git a/uploads/1715616496884_5fa531db62bbf.png b/uploads/1715616496884_5fa531db62bbf.png new file mode 100644 index 0000000..862b55f Binary files /dev/null and b/uploads/1715616496884_5fa531db62bbf.png differ diff --git a/uploads/1715616582718_5fa531db62bbf.png b/uploads/1715616582718_5fa531db62bbf.png new file mode 100644 index 0000000..862b55f Binary files /dev/null and b/uploads/1715616582718_5fa531db62bbf.png differ