diff --git a/api/package.json b/api/package.json index 3042fd7..6197479 100644 --- a/api/package.json +++ b/api/package.json @@ -10,6 +10,7 @@ "start:dev": "tsx watch src/server.ts", "start:prod": "node dist/server.js", "tsc": "tsc -p .", + "docs": "DOCS_GEN=true npx ts-node ./src/server.ts", "clean": "rimraf dist", "copy-assets": "ts-node src/utils/copy_assets", "build": "npm-run-all clean tsc copy-assets", @@ -29,6 +30,7 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.3.3", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "pad": "^3.2.0", "path": "^0.12.7", "yup": "^1.4.0", @@ -46,6 +48,7 @@ "@types/jsonwebtoken": "^9.0.6", "@types/mocha": "^9.0.0", "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.11", "@types/shelljs": "^0.8.11", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^5.5.0", diff --git a/api/src/controllers/beerController.ts b/api/src/controllers/beerController.ts new file mode 100644 index 0000000..be3ba06 --- /dev/null +++ b/api/src/controllers/beerController.ts @@ -0,0 +1,100 @@ +import { Request, Response } from 'express'; +import Beer from '../models/Beer'; +import { isValidObjectId, Types } from 'mongoose'; +import fs from 'fs'; +import path from 'path'; +import {Log} from 'nork' +import Docs from '../services/docsService'; +import { addExam, delExam, editExam, IBeer } from '../validators/beerValidator'; + +new Docs('beer', 'add', '/api/v1/beer/add', 'POST', 'beer add api', undefined, { ...addExam, photos: 'optional field | max 4 images | formData' }, 'status object | beer object'); +export async function add_post(req: Request, res: Response) { + try { + if (req.files) { + req.body.imgs = []; + const files: any = req.files; + files.forEach((el: any) => { + req.body.imgs.push(el.filename); + }); + } + const beer = new Beer(req.body); + await beer.save(); + res.status(201).json(Log.info(201, 'beer was created', beer)); + } catch (err: any) { + Log.error(500, 'error in add_post', err); + res.status(500).json(Log.error(500, 'something went wrong')); + } +} + +new Docs('beer', 'get', '/api/v1/beer/get', 'GET', 'beer get api', undefined, undefined, 'status object | array of beer objects'); +export async function get_get(req: Request, res: Response) { + try { + const beer = await Beer.find({}, '-__v'); + res.status(200).json(Log.info(200, 'beers fetched', beer)); + } catch (err: any) { + Log.error(500, 'error in get_get', err); + res.status(500).json(Log.error(500, 'something went wrong')); + } +} + +new Docs('beer', 'del', '/api/v1/beer/del', 'POST', 'beer del api', undefined, delExam, 'status object'); +export async function del_post(req: Request, res: Response) { + try { + if (!isValidObjectId(req.body._id)) throw Log.error(400, 'this is not valid _id'); + + const beer = await Beer.deleteOne(new Types.ObjectId(req.body._id)); + + if (beer.deletedCount > 0) { + res.status(200).json(Log.info(200, `beer ${req.body._id} deleted`)); + return; + } + throw Log.error(400, `beer ${req.body._id} does not exist`); + } catch (err: any) { + if (err.code) { + res.status(err.code).json(err); + return; + } + Log.error(500, 'error in del_post', err); + res.status(500).json(Log.error(500, 'something went wrong')); + } +} + +new Docs('beer', 'edit', '/api/v1/beer/edit', 'POST', 'beer edit api', undefined, { ...editExam, photos: 'optional field | max 4 images | formData' }, 'status object | beer data'); +export async function edit_post(req: Request, res: Response) { + try { + if (!isValidObjectId(req.body._id)) throw Log.error(400, 'this is not valid _id'); + + if (req.files) { + if (!req.body.imgs) { + req.body.imgs = []; + } + + if (typeof req.body.imgs == 'string') { + req.body.imgs = [req.body.imgs]; + } + + if (req.body.imgs.length + req.files.length > 4) { + req.body.imgs.forEach((el: string[]) => { + fs.rmSync(path.join(__dirname, '../../uploads/' + el)); + }); + throw Log.error(400, 'exceeds the 4 image limit'); + } + + const files: any = req.files; + files.forEach((el: any) => { + req.body.imgs.push(el.filename); + }); + } + + const payload = { ...req.body }; + const beer = await Beer.findOneAndUpdate(new Types.ObjectId(req.body._id), payload, { new: true }); + res.json(Log.info(200, `beer ${req.body._id} edited`, beer)); + } catch (err: any) { + if (err.code && typeof err.code == 'number') { + res.status(err.code).json(err); + return; + } + Log.error(500, 'error in del_post', err); + res.status(500).json(Log.error(500, 'something went wrong')); + } +} \ No newline at end of file diff --git a/api/src/controllers/docsController.ts b/api/src/controllers/docsController.ts new file mode 100644 index 0000000..33bf0f8 --- /dev/null +++ b/api/src/controllers/docsController.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import path from 'path'; +import fs from 'fs'; +import {Log} from 'nork' +import { Docs } from '../services/docsService'; + +new Docs('docs', 'get_all', '/api/v1', 'GET', 'Get docs json', undefined, undefined, 'docs json'); +export function docs_get(req: Request, res: Response) { + try { + res.json(JSON.parse(fs.readFileSync(path.join(__dirname, '../public/api.json')).toString())); + } catch (err: any) { + res.status(500).json(Log.error(500, 'api.json docs file does not exists under public folder')); + } +} diff --git a/api/src/middlewares/validateMulterRequest.ts b/api/src/middlewares/validateMulterRequest.ts new file mode 100644 index 0000000..6f7babd --- /dev/null +++ b/api/src/middlewares/validateMulterRequest.ts @@ -0,0 +1,15 @@ +import multer from 'multer'; +import {Log} from 'nork' +import { Request, Response, NextFunction } from 'express'; + +const validateMulterRequestMiddleware = async (err: any, req: Request, res: Response, next: NextFunction) => { + if (err instanceof multer.MulterError) { + Log.error(500, 'error while processing uploaded files', JSON.stringify(err)); + res.status(400).json(Log.error(400, 'error while processing uploaded files')); + return; + } else { + next(); + } +}; + +export default validateMulterRequestMiddleware; diff --git a/api/src/models/Beer.ts b/api/src/models/Beer.ts new file mode 100644 index 0000000..6710a3a --- /dev/null +++ b/api/src/models/Beer.ts @@ -0,0 +1,34 @@ +import path from 'path'; +import { Schema, model } from 'mongoose'; +import { IBeer } from '../validators/beerValidator'; + +const schema = new Schema( + { + name: { + type: String, + required: true + }, + degree: { + type: Number, + required: true + }, + packaging: { + type: String, + required: true + }, + brand: { + type: String, + required: true + }, + imgs: { + type: Array, + required: false, + default: [] + } + }, + { + timestamps: true + } +); + +export default model(path.basename(__filename).split('.')[0], schema); \ No newline at end of file diff --git a/api/src/public/api.json b/api/src/public/api.json new file mode 100644 index 0000000..e5ab6ef --- /dev/null +++ b/api/src/public/api.json @@ -0,0 +1 @@ +{"version":"2.0.0","endpoints":{"user":{"signup":{"name":"user","operation":"signup","route":"/api/v1/auth/signup","method":"POST","description":"user signup api","body":{"username":"testuser","email":"text@example.com","password":"Test1234"},"response":"status object"},"signin":{"name":"user","operation":"signin","route":"/api/v1/auth/signin","method":"POST","description":"user signin api","body":{"email":"text@example.com","password":"Test1234"},"response":"status object"},"logout":{"name":"user","operation":"logout","route":"/api/v1/auth/logout","method":"POST","description":"user logout api","body":{},"response":"status object"},"status":{"name":"user","operation":"status","route":"/api/v1/auth/status","method":"GET","description":"user login status api","response":"status code | user object"}},"beer":{"add":{"name":"beer","operation":"add","route":"/api/v1/beer/add","method":"POST","description":"beer add api","body":{"brand":"Pilsner Urqell","name":"Kozel","degree":11,"packaging":"can","photos":"optional field | max 4 images | formData"},"response":"status object | beer object"},"get":{"name":"beer","operation":"get","route":"/api/v1/beer/get","method":"GET","description":"beer get api","response":"status object | array of beer objects"},"del":{"name":"beer","operation":"del","route":"/api/v1/beer/del","method":"POST","description":"beer del api","body":{"_id":"6352b303b71cb62222f39895"},"response":"status object"},"edit":{"name":"beer","operation":"edit","route":"/api/v1/beer/edit","method":"POST","description":"beer edit api","body":{"_id":"6355b95dc03fad77bc380146","brand":"Pilsner Urqell","name":"Radegast","degree":12,"packaging":"bottle","imgs":[],"photos":"optional field | max 4 images | formData"},"response":"status object | beer data"}},"docs":{"get_all":{"name":"docs","operation":"get_all","route":"/api/v1","method":"GET","description":"Get docs json","response":"docs json"}}}} \ No newline at end of file diff --git a/api/src/routes/api_v1.ts b/api/src/routes/api_v1.ts index fcfb6c7..bedb87f 100644 --- a/api/src/routes/api_v1.ts +++ b/api/src/routes/api_v1.ts @@ -1,14 +1,29 @@ import { Router } from "express"; +import multer from 'multer'; +import path from 'path' import * as authController from "../controllers/authController"; -import validate from '../middlewares/validateRequest' -import * as AuthVal from '../validators/authValidator' +import * as beerController from "../controllers/beerController" +import * as docsController from "../controllers/docsController" import { requireAuth } from "../middlewares/authMiddleware"; +import validate from '../middlewares/validateRequest' +import valMulter from '../middlewares/validateMulterRequest'; +import * as AuthVal from '../validators/authValidator' +import * as BVal from '../validators/beerValidator'; + +const upload = multer({ dest: path.resolve(__dirname, '../../uploads') }); const router = Router(); +router.get('/', docsController.docs_get); + router.post("/auth/signup",validate(AuthVal.signup) , authController.signup_post); router.post("/auth/signin",validate(AuthVal.signin) , authController.signin_post); router.post("/auth/logout", requireAuth, authController.logout_post); router.get("/auth/status", requireAuth, authController.status_get); +router.post('/beer/add', [requireAuth, upload.array('photos', 4), valMulter, validate(BVal.add)], beerController.add_post); +router.get('/beer/get', [requireAuth], beerController.get_get); +router.post('/beer/del', [requireAuth, validate(BVal.del)], beerController.del_post); +router.post('/beer/edit', [requireAuth, upload.array('photos', 4), valMulter, validate(BVal.edit)], beerController.edit_post); + export default router; \ No newline at end of file diff --git a/api/src/validators/beerValidator.ts b/api/src/validators/beerValidator.ts new file mode 100644 index 0000000..183e353 --- /dev/null +++ b/api/src/validators/beerValidator.ts @@ -0,0 +1,65 @@ +import * as yup from 'yup'; +import YupPassword from 'yup-password'; +YupPassword(yup); +import { Schema } from 'mongoose'; + +interface mongooseAddition { + _id?: Schema.Types.ObjectId; + createdAt?: Schema.Types.Date; + updatedAt?: Schema.Types.Date; +} + +// Create +export const add = yup.object({ + brand: yup.string().required(), + name: yup.string().required(), + degree: yup.number().required(), + packaging: yup.string().required() +}); +export interface IBeer extends yup.InferType, mongooseAddition {} +export const addExam: IBeer = { + brand: 'Pilsner Urqell', + name: 'Kozel', + degree: 11, + packaging: 'can' +}; + +// Remove +export const del = yup.object({ + _id: yup.string().required() +}); +export interface IDel extends yup.InferType {} +export const delExam: IDel = { + _id: '6352b303b71cb62222f39895' +}; + +// Update +export const edit = yup.object({ + _id: yup.string().required(), + brand: yup.string(), + name: yup.string(), + degree: yup.number(), + packaging: yup.string(), + + //imgs: yup.mixed().when('$imgs', (imgs, schema) => + // Array.isArray(imgs) ? schema.array() : schema.string() + //) + + imgs: yup.mixed().test('is-array-or-string', 'imgs must be either an array or a string', value => + Array.isArray(value) || typeof value === 'string') + + //imgs: yup.mixed().when('isArray', { + // is: Array.isArray, + // then: yup.array(), + // otherwise: yup.string() + //}) +}); +export interface IEdit extends yup.InferType {} +export const editExam: IEdit = { + _id: '6355b95dc03fad77bc380146', + brand: 'Pilsner Urqell', + name: 'Radegast', + degree: 12, + packaging: 'bottle', + imgs: [] +}; diff --git a/api/tests/beer.test.ts b/api/tests/beer.test.ts new file mode 100644 index 0000000..fcb9447 --- /dev/null +++ b/api/tests/beer.test.ts @@ -0,0 +1,175 @@ +import supertest from 'supertest'; +import { app } from '../src/app'; +import { login } from './auth.test'; +import { addExam, delExam, editExam } from '../src/validators/beerValidator'; + +const request = supertest(app); + +describe('POST /api/v1/beer/add', () => { + const url = '/api/v1/beer/add'; + test('should drop 401 error', async () => { + const res = await request.post(url).send({}); + expect(res.statusCode).toBe(401); + }); + + test('should drop 400 ()', async () => { + const jwt = await login(); + const res = await request.post(url).set('Cookie', jwt).send({}); + + expect(res.statusCode).toBe(400); + }); + + test('should drop 400 (brand)', async () => { + const jwt = await login(); + const body: any = { ...addExam }; + delete body.brand; + const res = await request.post(url).set('Cookie', jwt).send(body); + + expect(res.statusCode).toBe(400); + }); + + test('should drop 201', async () => { + const jwt = await login(); + const res = await request.post(url).set('Cookie', jwt).send(addExam); + + expect(res.statusCode).toBe(201); + }); +}); + +describe('GET /api/v1/beer/get', () => { + const url = '/api/v1/beer/get'; + + test('should drop 401', async () => { + const res = await request.get(url).send(); + + expect(res.statusCode).toBe(401); + }); + + test('should drop 200', async () => { + const jwt = await login(); + const res = await request.get(url).set('Cookie', jwt).send(); + + expect(res.statusCode).toBe(200); + }); +}); + +describe('POST /api/v1/beer/del', () => { + const url = '/api/v1/beer/del'; + + test('should drop 401', async () => { + const res = await request.post(url).send(); + + expect(res.statusCode).toBe(401); + }); + + test('should drop 400', async () => { + const jwt = await login(); + const res = await request.post(url).set('Cookie', jwt).send(delExam); + + expect(res.statusCode).toBe(400); + }); + + test('should drop 400', async () => { + const jwt = await login(); + const res = await request.post(url).set('Cookie', jwt).send({ + _id: 'thisWillNotWork' + }); + + expect(res.statusCode).toBe(400); + }); + + test('should drop 200', async () => { + const jwt = await login(); + const req = await request.post('/api/v1/beer/add').set('Cookie', jwt).send(addExam); + const id = req.body.data._id; + const res = await request.post(url).set('Cookie', jwt).send({ + _id: id + }); + + expect(res.statusCode).toBe(200); + }); +}); + +describe('POST /api/v1/beer/edit', () => { + const url = '/api/v1/beer/edit'; + + test('should drop 401', async () => { + const res = await request.post(url).send(); + + expect(res.statusCode).toBe(401); + }); + + test('should drop 400', async () => { + const jwt = await login(); + + const payload: any = { ...editExam }; + delete payload._id; + + const res = await request.post(url).set('Cookie', jwt).send(payload); + + expect(res.statusCode).toBe(400); + }); + + test('should drop 200', async () => { + const jwt = await login(); + + const payload: any = { ...editExam }; + delete payload.name; + + const res = await request.post(url).set('Cookie', jwt).send(payload); + + expect(res.statusCode).toBe(200); + }); + + test('should drop 200', async () => { + const jwt = await login(); + + const payload: any = { ...editExam }; + delete payload.degree; + + const res = await request.post(url).set('Cookie', jwt).send(payload); + + expect(res.statusCode).toBe(200); + }); + + test('should drop 200', async () => { + const jwt = await login(); + + const payload: any = { ...editExam }; + delete payload.packaging; + + const res = await request.post(url).set('Cookie', jwt).send(payload); + + expect(res.statusCode).toBe(200); + }); + + test('should drop 200', async () => { + const jwt = await login(); + + const payload: any = { ...editExam }; + delete payload.brand; + + const res = await request.post(url).set('Cookie', jwt).send(payload); + + expect(res.statusCode).toBe(200); + }); + + test('should drop 200', async () => { + const jwt = await login(); + const req = await request.post('/api/v1/beer/add').set('Cookie', jwt).send(addExam); + const _id = req.body.data._id; + const payload = { ...editExam, _id: _id }; + + let res = await request.post(url).set('Cookie', jwt).send(payload); + + delete res.body.data._id; + delete res.body.data.__v; + delete res.body.data.createdAt; + delete res.body.data.updatedAt; + delete payload._id; + + const eq = JSON.stringify(Object.keys(res.body.data).sort()) === JSON.stringify(Object.keys(payload).sort()); + expect(res.statusCode).toBe(200); + expect(eq).toBe(true); + }); +}); \ No newline at end of file diff --git a/api/tests/docs.test.ts b/api/tests/docs.test.ts new file mode 100644 index 0000000..22d2157 --- /dev/null +++ b/api/tests/docs.test.ts @@ -0,0 +1,12 @@ +import request from 'supertest'; +import { app } from '../src/app'; + +describe('GET /api/v1', () => { + describe('should return json with docs', () => { + test('should respond with a 200 status code', async () => { + const response = await request(app).get('/api/v1').send({}); + expect(response.headers['content-type']).toMatch(/json/); + expect(response.statusCode).toBe(200); + }); + }); +}); \ No newline at end of file