diff --git a/README.md b/README.md index 7f93df74..036994b7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# TEAM NINJAS BACKEND +# E-COMMERCE WEB APPLICATION SERVER - TEAM NINJAS. -This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. +Our e-commerce web application server, developed by Team Ninjas, facilitates smooth online shopping with features like user authentication, product cataloging, and secure payments. It's built to enhance the user experience with high performance and reliability. Suitable for any online marketplace looking to grow. [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) [![Maintainability](https://api.codeclimate.com/v1/badges/839fc3fa18d25362cd8b/maintainability)](https://codeclimate.com/github/atlp-rwanda/e-commerce-ninjas-bn/maintainability) @@ -9,7 +9,6 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. [![CircleCI](https://dl.circleci.com/status-badge/img/gh/atlp-rwanda/e-commerce-ninjas-bn/tree/develop.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/atlp-rwanda/e-commerce-ninjas-bn/tree/develop) [![codecov](https://codecov.io/gh/atlp-rwanda/e-commerce-ninjas-bn/graph/badge.svg?token=6ZWudFPM1S)](https://codecov.io/gh/atlp-rwanda/e-commerce-ninjas-bn) - ## HOSTED SERVER URL [https://e-commerce-ninjas-backend.onrender.com/](https://e-commerce-ninjas-backend.onrender.com/) @@ -22,7 +21,6 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. [https://github.com/atlp-rwanda/e-commerce-ninjas-bn](https://github.com/atlp-rwanda/e-commerce-ninjas-bn) - ## COMPLETED FEATURES - Welcome Endpoint @@ -32,6 +30,8 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. - Login Endpoint - Admin Update Status Endpoint - Admin Update Role Endpoint +- Logout Endpoint +- Update User Profile Endpoint ## TABLE OF API ENDPOINTS SPECIFICATION AND DESCRIPTION @@ -43,9 +43,10 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. | 3 | GET | /api/auth/verify-email/:token | 200 OK | public | Verifying email | | 4 | POST | /api/auth/send-verify-email | 200 OK | public | Resend verification email | | 5 | POST | /api/auth/login | 200 OK | public | Login with Email and Password | -| 5 | PUT | /api/users/admin-update-role/:id | 200 OK | private | Update the user role by admin| -| 6 | PUT | /api/users/admin-update-user-status/:id | 200 OK | private | Admin Update Status Endpoint | -| 7 | PUT | /api/users/admin-update-role/:id | 200 OK | private | Admin Update Role Endpoint | +| 6 | PUT | /api/users/admin-update-user-status/:id | 200 OK | private | Admin Update Status Endpoint | +| 7 | PUT | /api/users/admin-update-role/:id | 200 OK | private | Admin Update Role Endpoint | +| 8 | POST | /api/auth/logout | 200 OK | private | Logout user | +| 9 | PUT | /api/users/user-update-profile/:id | 200 OK | private | Update User Profile Endpoint | ## INSTALLATION @@ -92,34 +93,34 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. ## INITILIAZE SEQUELIZE CLI 1. Initialize Sequelize CLI: - ```sh - npx sequelize-cli init - ``` + ```sh + npx sequelize-cli init + ``` 2. Generate Seeder: - ```sh - npx sequelize-cli seed:generate --name name-of-your-seeder - ``` + ```sh + npx sequelize-cli seed:generate --name name-of-your-seeder + ``` 3. Generate Migrations: - ```sh - npx sequelize-cli migration:generate --name name-of-your-migration - ``` + ```sh + npx sequelize-cli migration:generate --name name-of-your-migration + ``` 4. Define Migration: - Edit the generated migration file to include the tables you want to create. + Edit the generated migration file to include the tables you want to create. 5. Define Seeder Data: - Edit the generated seeder file to include the data you want to insert. + Edit the generated seeder file to include the data you want to insert. 6. Run the Seeder: - ```sh - npm run createAllSeeders - ``` + ```sh + npm run createAllSeeders + ``` 7. Run the Migration: - ```sh - npm run createAllTables - ``` + ```sh + npm run createAllTables + ``` 8. Delete the Seeder: - ```sh - npm run deleteAllSeeders - ``` + ```sh + npm run deleteAllSeeders + ``` 9. Delete the Migration: - ```sh - npm run deleteAllTables - ``` \ No newline at end of file + ```sh + npm run deleteAllTables + ``` diff --git a/src/__test__/BUILD.txt b/src/__test__/BUILD.txt new file mode 100644 index 00000000..0099cd3b --- /dev/null +++ b/src/__test__/BUILD.txt @@ -0,0 +1,11 @@ +Believe: Know that the problem you have identified has a solution, and trust that you and your team can build something to address it. + +Understand: Take the time to learn about the experience of the people affected by this problem / who will be using your solution – what does their journey look like? What are their pain points? What factors are affecting how they operate in / engage with the world? + +Invent: Create a Minimum Viable Product or prototype to test out an idea that you have! Be sure to keep in mind how your “invention” will meet your users’ needs. + +Listen: Get feedback from your users on your MVP / prototype and modify it as needed. + +Deliver: Continue to deliver refined versions of your MVP / prototype and improve it over time! + +Password@123 \ No newline at end of file diff --git a/src/__test__/testImage.jpg b/src/__test__/testImage.jpg new file mode 100644 index 00000000..858b5e4e Binary files /dev/null and b/src/__test__/testImage.jpg differ diff --git a/src/databases/models/tokens.ts b/src/databases/models/tokens.ts new file mode 100644 index 00000000..653face6 --- /dev/null +++ b/src/databases/models/tokens.ts @@ -0,0 +1,70 @@ +/* eslint-disable require-jsdoc */ +import { Model, DataTypes, Sequelize } from "sequelize"; + +interface TokenAttributes { + id: number; + userId: number; + device: string; + accessToken: string; + createdAt: Date; + updatedAt: Date; + expiresAt: Date; +} + +module.exports = (sequelize: Sequelize) => { + class Tokens extends Model implements TokenAttributes { + declare id: number; + declare userId: number; + declare device: string; + declare accessToken: string; + declare createdAt: Date; + declare updatedAt: Date; + declare expiresAt: Date; + } + + Tokens.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: new DataTypes.INTEGER(), + allowNull: false + }, + device: { + type: new DataTypes.STRING(280), + allowNull: false + }, + accessToken: { + type: new DataTypes.STRING(280), + allowNull: false + }, + createdAt: { + field: "createdAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updatedAt: { + field: "updatedAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + } + }, + { + sequelize, + tableName: "tokens", + timestamps: true, + modelName: "Tokens" + } + ); + + return Tokens; +}; diff --git a/src/helpers/index.ts b/src/helpers/index.ts index a316da93..42da55a7 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -5,7 +5,7 @@ import bcrypt from "bcrypt" dotenv.config const generateToken = (id: number) => { - return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: "1h" }); + return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: "12h" }); }; const decodeToken = (token: string) => { diff --git a/src/helpers/multer.ts b/src/helpers/multer.ts index ed2a8ca4..826fa6a5 100644 --- a/src/helpers/multer.ts +++ b/src/helpers/multer.ts @@ -1,14 +1,14 @@ -// import { Request } from "express"; -// import multer from "multer"; -// import path from "path"; +import { Request } from "express"; +import multer from "multer"; +import path from "path"; -// export default multer({ -// storage:multer.diskStorage({}), -// fileFilter:(req:Request,file:Express.Multer.File,cb)=>{ -// const ext = path.extname(file.originalname); -// if(ext!== ".png" && ext!== ".jpg" && ext!== ".jpeg"){ -// return cb(new Error("Only images are allowed")); -// } -// cb(null,true); -// } -// }) \ No newline at end of file +export default multer({ + storage:multer.diskStorage({}), + fileFilter:(req:Request,file:Express.Multer.File,cb)=>{ + const ext = path.extname(file.originalname); + if(ext!== ".png" && ext!== ".jpg" && ext!== ".jpeg"){ + return cb(new Error("Only images are allowed")); + } + cb(null,true); + } +}) \ No newline at end of file diff --git a/src/helpers/uploadImage.ts b/src/helpers/uploadImage.ts index 2a8872dc..067d619d 100644 --- a/src/helpers/uploadImage.ts +++ b/src/helpers/uploadImage.ts @@ -1,18 +1,18 @@ -// /* eslint-disable @typescript-eslint/no-explicit-any */ -// import { v2 as cloudinary } from "cloudinary"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { v2 as cloudinary } from "cloudinary"; -// cloudinary.config({ -// cloud_name: process.env.CLOUD_NAME, -// api_key: process.env.API_KEY, -// api_secret: process.env.API_SECRET -// }); +cloudinary.config({ + cloud_name: process.env.CLOUD_NAME, + api_key: process.env.API_KEY, + api_secret: process.env.API_SECRET + }); -// export const uploadImages = async (fileToUpload: any): Promise<{ public_id: string; secure_url: string }> => { -// const result = await cloudinary.uploader.upload(fileToUpload.path); -// return { -// public_id: result.public_id, -// secure_url: result.secure_url -// }; -// }; +export const uploadImages = async (fileToUpload: any): Promise<{ public_id: string; secure_url: string }> => { + const result = await cloudinary.uploader.upload(fileToUpload.path); + return { + public_id: result.public_id, + secure_url: result.secure_url + }; + }; -// export default uploadImages; \ No newline at end of file +export default uploadImages; diff --git a/src/middlewares/authorization.ts b/src/middlewares/authorization.ts index e863db1d..6931fdab 100644 --- a/src/middlewares/authorization.ts +++ b/src/middlewares/authorization.ts @@ -21,7 +21,7 @@ export const userAuthorization = function (roles: string[]) { } if (!token) { - res + return res .status(httpStatus.UNAUTHORIZED) .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); } @@ -32,7 +32,7 @@ export const userAuthorization = function (roles: string[]) { decoded.id, token ); if (!session) { - res + return res .status(httpStatus.UNAUTHORIZED) .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); } @@ -40,13 +40,13 @@ export const userAuthorization = function (roles: string[]) { const user = await authRepository.findUserByAttributes("id", decoded.id); if (!user) { - res + return res .status(httpStatus.UNAUTHORIZED) .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); } if (!roles.includes(user.role)) { - res + return res .status(httpStatus.UNAUTHORIZED) .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); } diff --git a/src/middlewares/validation.ts b/src/middlewares/validation.ts index 09e222f4..64ac79e7 100644 --- a/src/middlewares/validation.ts +++ b/src/middlewares/validation.ts @@ -5,7 +5,6 @@ import { UsersAttributes } from "../databases/models/users"; import Joi from "joi"; import httpStatus from "http-status"; import { comparePassword, decodeToken } from "../helpers"; -import { IRequest } from "../types"; const validation = (schema: Joi.ObjectSchema | Joi.ArraySchema) => async (req: Request, res: Response, next: NextFunction) => { try { @@ -68,7 +67,7 @@ const isAccountVerified = async (req: any, res: Response, next: NextFunction) => return res.status(httpStatus.BAD_REQUEST).json({ message: "Account already verified." }); } - const session = await authRepositories.findSessionByUserId(user.id); + const session = await authRepositories.findSessionByAttributes("userId",user.id); if (!session) { return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid token." }); } @@ -81,21 +80,59 @@ const isAccountVerified = async (req: any, res: Response, next: NextFunction) => } } -const verifyUserCredentials = async (req: Request, res: Response, next: NextFunction) => { - try { - const user: UsersAttributes = await authRepositories.findUserByAttributes("email", req.body.email); - if (!user) { - return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid Email or Password", data: null }); - } - const passwordMatches = await comparePassword(req.body.password, user.password) - if (!passwordMatches) return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid Email or Password", data: null }); - (req as IRequest).loginUserId = user.id; - return next(); - } catch (error) { - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ message: "Server error", data: error.message }) +const verifyUserCredentials = async ( + req: any, + res: Response, + next: NextFunction +) => { + try { + const user: UsersAttributes = await authRepositories.findUserByAttributes( + "email", + req.body.email + ); + if (!user) { + return res + .status(httpStatus.BAD_REQUEST) + .json({ message: "Invalid Email or Password" }); } -} + const passwordMatches = await comparePassword( + req.body.password, + user.password + ); + if (!passwordMatches) { + return res + .status(httpStatus.BAD_REQUEST) + .json({ message: "Invalid Email or Password" }); + } + + req.user = user; + + const device = req.headers["user-agent"]; + if (!device) { + return next(); + } + + const existingToken = await authRepositories.findTokenByDeviceIdAndUserId( + device, + user.id + ); + if (existingToken) { + return res + .status(httpStatus.OK) + .json({ + message: "Logged in successfully", + data: { token: existingToken } + }); + } else { + return next(); + } + } catch (error) { + return res + .status(httpStatus.INTERNAL_SERVER_ERROR) + .json({ message: "Internal Server error", data: error.message }); + } +}; diff --git a/src/modules/auth/controller/authControllers.ts b/src/modules/auth/controller/authControllers.ts index 772ca29f..6979a87a 100644 --- a/src/modules/auth/controller/authControllers.ts +++ b/src/modules/auth/controller/authControllers.ts @@ -4,57 +4,108 @@ import userRepositories from "../repository/authRepositories"; import { generateToken } from "../../../helpers"; import httpStatus from "http-status"; import { UsersAttributes } from "../../../databases/models/users"; -import { IRequest } from "../../../types"; - import authRepositories from "../repository/authRepositories"; import { sendVerificationEmail } from "../../../services/sendEmail"; const registerUser = async (req: Request, res: Response): Promise => { - try { - const register: UsersAttributes = await userRepositories.createUser(req.body); - const token: string = generateToken(register.id); - const session = { userId: register.id, device: req.headers["user-device"], token: token, otp: null }; - await authRepositories.createSession(session); - await sendVerificationEmail(register.email, "Verification Email", `${process.env.SERVER_URL_PRO}/api/auth/verify-email/${token}`); - res.status(httpStatus.CREATED).json({ message: "Account created successfully. Please check email to verify account.", data: { user: register } }) - } catch (error) { - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); - } - -} + try { + const register: UsersAttributes = await authRepositories.createUser( + req.body + ); + const token: string = generateToken(register.id); + const session = { + userId: register.id, + device: req.headers["user-agent"], + token: token, + otp: null + }; + await authRepositories.createSession(session); + await sendVerificationEmail( + register.email, + "Verification Email", + `${process.env.SERVER_URL_PRO}/api/auth/verify-email/${token}` + ); + res.status(httpStatus.CREATED).json({ + message: + "Account created successfully. Please check email to verify account.", + data: { user: register } + }); + } catch (error) { + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + status: httpStatus.INTERNAL_SERVER_ERROR, + message: error.message + }); + } +}; const sendVerifyEmail = async (req: any, res: Response) => { - try { - await sendVerificationEmail(req.user.email, "Verification Email", `${process.env.SERVER_URL_PRO}/api/auth/verify-email/${req.session.token}`); - res.status(httpStatus.OK).json({ status: httpStatus.OK, message: "Verification email sent successfully." }); - } catch (error) { - return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); - } -} + try { + await sendVerificationEmail( + req.user.email, + "Verification Email", + `${process.env.SERVER_URL_PRO}/api/auth/verify-email/${req.session.token}` + ); + res.status(httpStatus.OK).json({ + status: httpStatus.OK, + message: "Verification email sent successfully." + }); + } catch (error) { + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + status: httpStatus.INTERNAL_SERVER_ERROR, + message: error.message + }); + } +}; const verifyEmail = async (req: any, res: Response) => { - try { - await authRepositories.destroySession(req.user.id, req.session.token) - await authRepositories.updateUserByAttributes("isVerified", true, "id", req.user.id); - res.status(httpStatus.OK).json({ status: httpStatus.OK, message: "Account verified successfully, now login." }); - } catch (error) { - return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); - } + try { + await authRepositories.destroySession(req.user.id, req.session.token) + await authRepositories.updateUserByAttributes("isVerified", true, "id", req.user.id); + res.status(httpStatus.OK).json({ status: httpStatus.OK, message: "Account verified successfully, now login." }); + } catch (error) { + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); + } } -const loginUser = async (req: Request, res: Response) => { - try { - const userId = (req as IRequest).loginUserId; - const token = generateToken(userId); - const session = { userId, device: req.headers["user-device"], token: token, otp: null }; - await userRepositories.createSession(session); - res.status(httpStatus.OK).json({ message: "Logged in successfully", data: { token } }); - } - catch (err) { - return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ message: "Server error", data: err.message }); - } -} +const loginUser = async (req: any, res: Response) => { + try { + const token = generateToken(req.user.id); + const session = { + userId: req.user.id, + device: req.headers["user-agent"], + token: token, + otp: null + }; + await userRepositories.createSession(session); + res + .status(httpStatus.OK) + .json({ message: "Logged in successfully", data: { token } }); + } catch (err) { + return res + .status(httpStatus.INTERNAL_SERVER_ERROR) + .json({ message: "Internal Server error", data: err.message }); + } +}; +const logoutUser = async (req: any, res: Response) => { + try { + await authRepositories.destroySession(req.user.id, req.session.token) + res.status(httpStatus.OK).json({ message: "Successfully logged out" }); + } catch (err) { + return res + .status(httpStatus.INTERNAL_SERVER_ERROR) + .json({ + status: httpStatus.INTERNAL_SERVER_ERROR, + message: "Internal Server error" + }); + } +}; -export default { registerUser, sendVerifyEmail, verifyEmail, loginUser } \ No newline at end of file +export default { + registerUser, + sendVerifyEmail, + verifyEmail, + loginUser, + logoutUser +}; diff --git a/src/modules/auth/repository/authRepositories.ts b/src/modules/auth/repository/authRepositories.ts index 9f4504fb..2eea1d7c 100644 --- a/src/modules/auth/repository/authRepositories.ts +++ b/src/modules/auth/repository/authRepositories.ts @@ -3,8 +3,8 @@ import Users from "../../../databases/models/users"; import Session from "../../../databases/models/session"; -const createUser = async (body: any) => { - return await Users.create(body); +const createUser = async (body: any) => { + return await Users.create({ ...body, role:"buyer" }); }; const findUserByAttributes = async (key: string, value: any) => { @@ -28,18 +28,22 @@ const createSession = async (body: any) => { return await Session.create(body); }; -const findSessionByUserId = async (userId: number) => { - return await Session.findOne({ where: { userId } }); -}; +const findSessionByAttributes = async( key:string, value: any ) => { + return await Session.findOne({ where: { [key]:value } }); +} const findSessionByUserIdAndToken = async (userId: number, token: string) => { - return await Session.findOne({where: {token, userId}}) -} + return await Session.findOne({ where: { token, userId } }); +}; +const findTokenByDeviceIdAndUserId = async (device: string, userId: number)=>{ + const session = await Session.findOne({ where: {device, userId} }); + return session.token; +} -const destroySession = async (userId: number, token: string) => { - return await Session.destroy({ where: { userId, token } }); -}; +const destroySession = async (userId: number, token:string) =>{ + return await Session.destroy({ where: {userId, token } }); +} export default { createUser, @@ -47,6 +51,7 @@ export default { findUserByAttributes, destroySession, updateUserByAttributes, - findSessionByUserId, - findSessionByUserIdAndToken + findSessionByAttributes, + findSessionByUserIdAndToken, + findTokenByDeviceIdAndUserId, }; diff --git a/src/modules/auth/test/auth.spec.ts b/src/modules/auth/test/auth.spec.ts index 045bb80f..b055d6c9 100644 --- a/src/modules/auth/test/auth.spec.ts +++ b/src/modules/auth/test/auth.spec.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable comma-dangle */ +/* eslint quotes: "off" */ import { Request, Response } from "express"; import chai, { expect } from "chai"; import chaiHttp from "chai-http"; @@ -9,8 +11,10 @@ import { isUserExist } from "../../../middlewares/validation"; import authRepositories from "../repository/authRepositories"; import Users from "../../../databases/models/users"; import Session from "../../../databases/models/session"; -import { sendVerificationEmail, transporter } from "../../../services/sendEmail"; - +import { + sendVerificationEmail, + transporter +} from "../../../services/sendEmail"; chai.use(chaiHttp); const router = () => chai.request(app); @@ -19,6 +23,7 @@ let userId: number = 0; let verifyToken: string | null = null; describe("Authentication Test Cases", () => { + let token; afterEach(async () => { const tokenRecord = await Session.findOne({ where: { userId } }); @@ -39,7 +44,10 @@ describe("Authentication Test Cases", () => { expect(response.body).to.be.an("object"); expect(response.body).to.have.property("data"); userId = response.body.data.user.id; - expect(response.body).to.have.property("message", "Account created successfully. Please check email to verify account."); + expect(response.body).to.have.property( + "message", + "Account created successfully. Please check email to verify account." + ); done(error); }); }); @@ -55,9 +63,12 @@ describe("Authentication Test Cases", () => { expect(res.status).to.equal(httpStatus.OK); expect(res.body).to.be.an("object"); expect(res.body).to.have.property("status", httpStatus.OK); - expect(res.body).to.have.property("message", "Account verified successfully, now login."); + expect(res.body).to.have.property( + "message", + "Account verified successfully, now login." + ); done(err); - }) + }); }); it("should return validation error and 400", (done) => { @@ -75,7 +86,6 @@ describe("Authentication Test Cases", () => { }); }); - it("Should be able to login a registered user", (done) => { router() .post("/api/auth/login") @@ -89,12 +99,59 @@ describe("Authentication Test Cases", () => { expect(response.body).to.have.property("data"); expect(response.body.message).to.be.a("string"); expect(response.body.data).to.have.property("token"); + token = response.body.data.token; done(error); }); }); + it("Should be able to logout user", (done) => { + router() + .post("/api/auth/logout") + .set("Authorization", `Bearer ${token}`) + .end((err, res) => { + expect(res).to.have.status(httpStatus.OK); + expect(res.body).to.have.property("message", "Successfully logged out"); + done(err); + }); + }); + + + it("Should be able to login a registered user", (done) => { + router() + .post("/api/auth/login") + .send({ + email: "ecommerceninjas45@gmail.com", + password: "userPassword@123", + }) + .end((error, response) => { + expect(response.status).to.equal(httpStatus.OK); + expect(response.body).to.be.a("object"); + expect(response.body).to.have.property("data"); + expect(response.body.message).to.be.a("string"); + expect(response.body.data).to.have.property("token"); + token = response.body.data.token; + done(error); + }); + }); + + it("Should return error on logout", (done) => { + sinon + .stub(authRepositories, "destroySession") + .throws(new Error("Database Error")); + router() + .post("/api/auth/logout") + .set("Authorization", `Bearer ${token}`) + .end((err, res) => { + expect(res).to.have.status(httpStatus.INTERNAL_SERVER_ERROR); + expect(res.body).to.have.property("message", "Internal Server error"); + done(err); + }); + }); + it("should return internal server error on login", (done) => { - sinon.stub(authRepositories, "createSession").throws(new Error("Database error")); + sinon + .stub(authRepositories, "createSession") + .throws(new Error("Database error")); router() .post("/api/auth/login") .send({ @@ -130,7 +187,10 @@ describe("Authentication Test Cases", () => { .end((error, response) => { expect(response).to.have.status(httpStatus.BAD_REQUEST); expect(response.body).to.be.a("object"); - expect(response.body).to.have.property("message", "Invalid Email or Password"); + expect(response.body).to.have.property( + "message", + "Invalid Email or Password" + ); done(error); }); }); @@ -145,15 +205,16 @@ describe("Authentication Test Cases", () => { .end((error, response) => { expect(response).to.have.status(httpStatus.BAD_REQUEST); expect(response.body).to.be.a("object"); - expect(response.body).to.have.property("message", "Invalid Email or Password"); + expect(response.body).to.have.property( + "message", + "Invalid Email or Password" + ); done(error); }); }); - }); describe("isUserExist Middleware", () => { - before(() => { app.post("/auth/register", isUserExist, (req: Request, res: Response) => { res.status(200).json({ message: "success" }); @@ -202,31 +263,43 @@ describe("isUserExist Middleware", () => { expect(res).to.have.status(httpStatus.BAD_REQUEST); expect(res.body).to.be.an("object"); expect(res.body).to.have.property("status", httpStatus.BAD_REQUEST); - expect(res.body).to.have.property("message", "Account already exists. Please verify your account"); + expect(res.body).to.have.property( + "message", + "Account already exists. Please verify your account" + ); done(err); }); }); - it("should return internal server error", (done) => { - sinon.stub(authRepositories, "findUserByAttributes").throws(new Error("Database error")); + sinon + .stub(authRepositories, "findUserByAttributes") + .throws(new Error("Database error")); router() .post("/auth/register") .send({ email: "usertesting@gmail.com" }) .end((err, res) => { expect(res).to.have.status(httpStatus.INTERNAL_SERVER_ERROR); expect(res.body).to.be.an("object"); - expect(res.body).to.have.property("status", httpStatus.INTERNAL_SERVER_ERROR); + expect(res.body).to.have.property( + "status", + httpStatus.INTERNAL_SERVER_ERROR + ); expect(res.body).to.have.property("message", "Database error"); done(err); }); }); it("should return internal server error on login", (done) => { - sinon.stub(authRepositories, "findUserByAttributes").throws(new Error("Database error")); + sinon + .stub(authRepositories, "findUserByAttributes") + .throws(new Error("Database error")); router() .post("/api/auth/login") - .send({ email: "ecommerceninjas45@gmail.com", password: "userPassword@123" }) + .send({ + email: "ecommerceninjas45@gmail.com", + password: "userPassword@123" + }) .end((err, res) => { expect(res).to.have.status(httpStatus.INTERNAL_SERVER_ERROR); done(err); @@ -246,14 +319,15 @@ describe("isUserExist Middleware", () => { done(err); }); }); - }); describe("POST /auth/register - Error Handling", () => { let registerUserStub: sinon.SinonStub; beforeEach(() => { - registerUserStub = sinon.stub(authRepositories, "createUser").throws(new Error("Test error")); + registerUserStub = sinon + .stub(authRepositories, "createUser") + .throws(new Error("Test error")); }); afterEach(() => { @@ -290,7 +364,7 @@ describe("isAccountVerified Middleware", () => { expect(res.status).to.equal(httpStatus.NOT_FOUND); expect(res.body).to.have.property("message", "Account not found."); done(err); - }) + }); }); it("should return 'Account already verified' if user is already verified", (done) => { @@ -310,12 +384,13 @@ describe("isAccountVerified Middleware", () => { .send({ email: "user@example.com" }) .end((err, res) => { expect(res.status).to.equal(httpStatus.BAD_REQUEST); - expect(res.body).to.have.property("message", "Account already verified."); + expect(res.body).to.have.property( + "message", + "Account already verified." + ); done(err); }); }); - - }); describe("Authentication Test Cases", () => { @@ -323,8 +398,14 @@ describe("Authentication Test Cases", () => { let findSessionByUserIdStub: sinon.SinonStub; beforeEach(() => { - findUserByAttributesStub = sinon.stub(authRepositories, "findUserByAttributes"); - findSessionByUserIdStub = sinon.stub(authRepositories, "findSessionByUserId"); + findUserByAttributesStub = sinon.stub( + authRepositories, + "findUserByAttributes" + ); + findSessionByUserIdStub = sinon.stub( + authRepositories, + "findSessionByAttributes" + ); }); afterEach(() => { @@ -343,7 +424,10 @@ describe("Authentication Test Cases", () => { .send({ email: "user@example.com" }) .end((err, res) => { expect(res).to.have.status(httpStatus.OK); - expect(res.body).to.have.property("message", "Verification email sent successfully."); + expect(res.body).to.have.property( + "message", + "Verification email sent successfully." + ); done(err); }); }); @@ -352,7 +436,7 @@ describe("Authentication Test Cases", () => { const mockSession = { token: "testToken" }; findUserByAttributesStub.resolves(mockUser); - findSessionByUserIdStub.resolves(mockSession) + findSessionByUserIdStub.resolves(mockSession); findSessionByUserIdStub.resolves(null); router() .post("/api/auth/send-verify-email") @@ -391,5 +475,4 @@ describe("sendVerificationEmail", () => { expect(error).to.be.an("error"); } }); - }); \ No newline at end of file diff --git a/src/modules/user/controller/userControllers.ts b/src/modules/user/controller/userControllers.ts index 40f1e77c..78e65b94 100644 --- a/src/modules/user/controller/userControllers.ts +++ b/src/modules/user/controller/userControllers.ts @@ -1,6 +1,7 @@ // user Controllers import { Request, Response } from "express"; - +import uploadImages from "../../../helpers/uploadImage"; +import userRepositories from "../repository/userRepositories"; import authRepositories from "../../auth/repository/authRepositories"; import httpStatus from "http-status"; @@ -30,6 +31,26 @@ const updateUserStatus = async (req: Request, res: Response): Promise => { } }; +const getUserDetails = async(req:Request,res:Response)=>{ + try { + const Users = await authRepositories.findUserByAttributes("id", req.params.id); + res.status(httpStatus.OK).json({status: httpStatus.OK,Users}); + } catch (error) { + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }) + } +} + +const updateUserProfile = async (req: Request, res: Response) => { + try { + const upload = await uploadImages(req.file); + const userData = { ...req.body, profilePicture:upload.secure_url }; + const updatedUser = await userRepositories.updateUserProfile(userData, Number(req.params.id)); + res.status(httpStatus.OK).json({status:httpStatus.OK, data:updatedUser}); + } catch (error) { + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({status:httpStatus.INTERNAL_SERVER_ERROR, error: error.message}); + } +} + -export default { updateUserStatus,updateUserRole }; \ No newline at end of file +export default { updateUserStatus,updateUserRole,getUserDetails ,updateUserProfile}; \ No newline at end of file diff --git a/src/modules/user/repository/userRepositories.ts b/src/modules/user/repository/userRepositories.ts index 8b137891..661514f2 100644 --- a/src/modules/user/repository/userRepositories.ts +++ b/src/modules/user/repository/userRepositories.ts @@ -1 +1,9 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Users from "../../../databases/models/users"; +const updateUserProfile = async (user: any, id:number) => { + await Users.update({...user},{where:{id},returning:true}) +const updateUser = await Users.findOne({where:{id}}) + return updateUser; +} +export default {updateUserProfile}; diff --git a/src/modules/user/test/user.spec.ts b/src/modules/user/test/user.spec.ts index 34823c9a..305953da 100644 --- a/src/modules/user/test/user.spec.ts +++ b/src/modules/user/test/user.spec.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable comma-dangle */ +/* eslint quotes: "off" */ import chai, { expect } from "chai"; import chaiHttp from "chai-http"; import sinon, { SinonStub } from "sinon"; @@ -8,6 +10,12 @@ import app from "../../../index"; import Users from "../../../databases/models/users"; import authRepositories from "../../auth/repository/authRepositories" import { UsersAttributes } from "../../../databases/models/users"; +import path from "path"; +import fs from 'fs' +import uploadImages from "../../../helpers/uploadImage"; +import { v2 as cloudinary } from "cloudinary"; +const imagePath = path.join(__dirname, '../../../__test__/testImage.jpg'); +const imageBuffer = fs.readFileSync(imagePath) chai.use(chaiHttp); @@ -139,11 +147,11 @@ describe("Admin update User roles", () => { where: {} }) }) - after(async () => { - await Users.destroy({ - where: {} - }) - }) + // after(async () => { + // await Users.destroy({ + // where: {} + // }) + // }) let userIdd: number = null; @@ -217,4 +225,141 @@ describe("Admin update User roles", () => { }) -}); \ No newline at end of file +}); + +describe("updateUserProfile", () => { + let profileId :number = null; +let token + +it("should register a new user", (done) => { +router() + .post("/api/auth/register") + .send({ + email: "salt23@gmail.com", + password: "userPassword@123" + }) + .end((error, response) => { + expect(response.status).to.equal(httpStatus.CREATED); + expect(response.body).to.be.an("object"); + expect(response.body).to.have.property("data"); + profileId = response.body.data.user.id; + expect(response.body).to.have.property("message", "Account created successfully. Please check email to verify account."); + done(error); + }); +}); + +it("Should be able to login a registered user", (done) => { + router() + .post("/api/auth/login") + .send({ + email: "salt23@gmail.com", + password: "userPassword@123" + }) + .end((error, response) => { + expect(response.status).to.equal(httpStatus.OK); + expect(response.body).to.be.a("object"); + expect(response.body).to.have.property("data"); + expect(response.body.message).to.be.a("string"); + expect(response.body.data).to.have.property("token"); + token = response.body.data.token; + done(error); + }); +}); + + +it("Should be able to get", (done) => { +router() + .get(`/api/users/user-get-profile/${profileId}`) + .end((error, response) => { + expect(response).to.have.status(200); + expect(response.body).to.be.a("object"); + done(error); + }); +}); + +it("should update profile ", (done) => { + router().put(`/api/users/user-update-profile/${profileId}`) + .set("Authorization", `Bearer ${token}`) + .field('firstName', 'MANISHIMWE') + .field('lastName', 'Salton Joseph') + .field('phone', '787312593') + .field('gender', 'male') + .field('birthDate', '1943-02-04') + .field('language', 'english') + .field('currency', 'USD') + .attach("profilePicture",imageBuffer,'testImage.jpg') + .end((error, response) => { + + expect(response.status).to.equal(200); + done(error); + }); +}); +it("should return error when user id is invalid", (done) => { +router().put("/api/users/user-update-profile/-1") +.set("Authorization", `Bearer ${token}`) +.send({ + "firstName": "MANISHIMWE", + "lastName": "Salton Joseph", + "phone": "787312593", + "gender": "male", + "birthDate": "1943-02-04T00:00:00.000Z", + "language": "english", + "currency": "USD" +}).end((error, response) => { + + expect(response.status).to.equal(500); + done(error); +}); +}); + + +describe('uploadImages', () => { + let uploadStub: sinon.SinonStub; + + beforeEach(() => { + uploadStub = sinon.stub(cloudinary.uploader, 'upload'); + }); + + afterEach(() => { + uploadStub.restore(); + }); + + it('should upload an image and return the public_id and secure_url', async () => { + const fileToUpload = { path: 'path/to/file.jpg' }; + const mockResult = { + public_id: 'mock_public_id', + secure_url: 'https://mock_secure_url.com', + }; + + uploadStub.resolves(mockResult); + + const result = await uploadImages(fileToUpload); + + expect(uploadStub.calledOnceWith(fileToUpload.path)).to.be.true; + expect(result).to.deep.equal(mockResult); + }); + + it('should handle errors from the upload process', async () => { + const fileToUpload = { path: 'path/to/file.jpg' }; + const mockError = new Error('Upload failed'); + + uploadStub.rejects(mockError); + + try { + await uploadImages(fileToUpload); + expect.fail('Expected error was not thrown'); + } catch (error) { + expect(error).to.be.an('error'); + expect(error.message).to.equal('Upload failed'); + } + }); + + + after(async () => { + await Users.destroy({ + where: {} + }) + }); + +}); +}) \ No newline at end of file diff --git a/src/modules/user/validation/userValidations.ts b/src/modules/user/validation/userValidations.ts index 106ca645..3da2fac8 100644 --- a/src/modules/user/validation/userValidations.ts +++ b/src/modules/user/validation/userValidations.ts @@ -1,4 +1,16 @@ import Joi from "joi"; +interface User { + firstName: string; + lastName: string; + email: string; + phone: number; + profilePicture?: string; + gender: "male" | "female" | "other"; + birthDate: string; + language: string; + currency: string; + role: "buyer" | "seller" | "admin"; +} export const statusSchema = Joi.object({ status: Joi.string().valid("enabled", "disabled").required().messages({ @@ -16,3 +28,49 @@ export const roleSchema = Joi.object({ "any.only": "Only Admin, Buyer and Seller are allowed." }) }); +const userSchema = Joi.object({ + firstName: Joi.string().messages({ + "string.base": "firstName should be a type of text", + "string.empty": "firstName cannot be an empty field", + "any.required": "firstName is required" + }), + lastName: Joi.string().messages({ + "string.base": "lastName should be a type of text", + "string.empty": "lastName cannot be an empty field", + "any.required": "lastName is required" + }), + phone: Joi.number().messages({ + "number.base": "phone number should be a type of number", + "any.required": "phone number is required" + }), + profilePicture: Joi.string().uri().optional().messages({ + "string.base": "profilePicture should be a type of text", + "string.uri": "profilePicture must be a valid URI" + }), + gender: Joi.string().valid("male", "female", "other").messages({ + "string.base": "gender should be a type of text", + "any.only": "gender must be one of [male, female, other]", + "any.required": "gender is required" + }), + birthDate: Joi.date().iso().messages({ + "date.base": "birthDate should be a valid date", + "date.iso": "birthDate must be in ISO format", + "any.required": "birthDate is required" + }), + language: Joi.string().messages({ + "string.base": "language should be a type of text", + "string.empty": "language cannot be an empty field", + "any.required": "language is required" + }), + currency: Joi.string().messages({ + "string.base": "currency should be a type of text", + "string.empty": "currency cannot be an empty field", + "any.required": "currency is required" + }), + role: Joi.string().valid("buyer", "seller", "admin").messages({ + "string.base": "role should be a type of text", + "any.only": "role must be one of [buyer, seller, admin]", + "any.required": "role is required" + }) +}); +export {userSchema}; diff --git a/src/routes/authRouter.ts b/src/routes/authRouter.ts index 2486cdf4..f1143de1 100644 --- a/src/routes/authRouter.ts +++ b/src/routes/authRouter.ts @@ -1,15 +1,46 @@ import { Router } from "express"; import authControllers from "../modules/auth/controller/authControllers"; -import { validation, isUserExist, isAccountVerified, verifyUserCredentials } from "../middlewares/validation"; -import { emailSchema, credentialSchema } from "../modules/auth/validation/authValidations"; - +import { + validation, + isUserExist, + isAccountVerified, + verifyUserCredentials +} from "../middlewares/validation"; +import { + emailSchema, + credentialSchema +} from "../modules/auth/validation/authValidations"; +import { userAuthorization } from "../middlewares/authorization"; const router: Router = Router(); -router.post("/register", validation(credentialSchema), isUserExist, authControllers.registerUser); -router.get("/verify-email/:token", isAccountVerified, authControllers.verifyEmail); -router.post("/send-verify-email", validation(emailSchema), isAccountVerified, authControllers.sendVerifyEmail); -router.post("/login", validation(credentialSchema), verifyUserCredentials, authControllers.loginUser); - +router.post( + "/register", + validation(credentialSchema), + isUserExist, + authControllers.registerUser +); +router.get( + "/verify-email/:token", + isAccountVerified, + authControllers.verifyEmail +); +router.post( + "/send-verify-email", + validation(emailSchema), + isAccountVerified, + authControllers.sendVerifyEmail +); +router.post( + "/login", + validation(credentialSchema), + verifyUserCredentials, + authControllers.loginUser +); +router.post( + "/logout", + userAuthorization(["buyer", "seller", "admin"]), + authControllers.logoutUser +); -export default router; \ No newline at end of file +export default router; diff --git a/src/routes/userRouter.ts b/src/routes/userRouter.ts index ec66fdda..d22c1d30 100644 --- a/src/routes/userRouter.ts +++ b/src/routes/userRouter.ts @@ -1,12 +1,16 @@ import { Router } from "express"; import userControllers from "../modules/user/controller/userControllers"; import {isUserExist, validation} from "../middlewares/validation"; -import { statusSchema,roleSchema } from "../modules/user/validation/userValidations"; - +import { statusSchema,roleSchema , userSchema} from "../modules/user/validation/userValidations"; +import upload from "../helpers/multer"; +import { userAuthorization } from "../middlewares/authorization"; const router: Router = Router() router.put("/admin-update-user-status/:id", validation(statusSchema), isUserExist, userControllers.updateUserStatus); router.put("/admin-update-role/:id",validation(roleSchema),isUserExist, userControllers.updateUserRole); +router.get("/user-get-profile/:id",userControllers.getUserDetails) +router.put("/user-update-profile/:id",userAuthorization(["buyer", "seller", "admin"]),upload.single("profilePicture"),validation(userSchema),userControllers.updateUserProfile) + export default router; \ No newline at end of file diff --git a/src/types/index.d.ts b/src/types/index.d.ts index d04faddd..c2a56d68 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -13,4 +13,5 @@ export interface ILogin{ export interface IRequest extends Request{ loginUserId?: number; + token; } \ No newline at end of file diff --git a/swagger.json b/swagger.json index bf6be133..727a583a 100644 --- a/swagger.json +++ b/swagger.json @@ -27,18 +27,15 @@ { "name": "Admin User Routes", "description": "" + }, + { + "name": "Update Profile Route", + "description": " Endpoint PUT Route" } ], - "schemes": [ - "http", - "https" - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], + "schemes": ["http", "https"], + "consumes": ["application/json"], + "produces": ["application/json"], "paths": { "/": { "get": { @@ -294,10 +291,36 @@ } } }, + "/api/auth/logout": { + "post": { + "tags": ["Authentication Routes"], + "summary": "Logout User", + "description": "Logout a logged in user", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Logout successful" + }, + "401": { + "description": "No Token provided" + }, + "500": { + "description": "Server error" + } + } + } + }, "/api/users/admin-update-user-status/{id}": { "put": { - "tags": [ - "Admin User Routes" + "tags": ["Admin User Routes"], + "security": [ + { + "bearerAuth": [] + } ], "summary": "Change User Status", "description": "This endpoint allows an admin to change User account Status by providing the userId.", @@ -324,9 +347,7 @@ "type": "string" } }, - "required": [ - "status" - ] + "required": ["status"] } } } @@ -428,20 +449,18 @@ } } }, - + "/api/users/admin-update-role/{id}": { "put": { - "tags": [ - "Admin User Routes" - ], + "tags": ["Admin User Routes"], "summary": "Update user role", "description": "This endpoint allows admin to update a user's role", "parameters": [ { - "in":"path", + "in": "path", "name": "id", "type": "string", - "description":"Pass the ID" + "description": "Pass the ID" } ], "requestBody": { @@ -454,20 +473,19 @@ "properties": { "role": { "type": "string", - "enum": [ - "Admin", - "Buyer", - "Seller" - ] + "enum": ["Admin", "Buyer", "Seller"] } }, - "required": [ - "role" - ] + "required": ["role"] } } } }, + "security": [ + { + "bearerAuth": [] + } + ], "responses": { "200": { "description": "User role updated successfully", @@ -546,6 +564,76 @@ } } } + }, + "/api/users/user-update-profile/{id}":{ + "put":{ + "tags": ["Update Profile Route"], + "summary": "Update a UserProfile", + "description": "By the use of initial/wrong GET endpoint, you will be able to see welcome message (Welcome to E-Commerce-Ninja-BackEnd)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. (Welcome to E-Commerce-Ninja-BackEnd.)" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "UserProfile ID", + "required": true + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "firstName": { "type": "string", + "example": "MANISHIMWE" }, + "lastName": { "type": "string", + "example": "Salton" }, + "phone": { + "type": "number", + "example": "0787312593" + }, + "profilePicture":{ "type": "string" , + "example": "https://www.pexels.com/photo/bicycle-under-building-wall-with-painted-pixel-eyes-15997337/"}, + "gender" :{ "type": "string" , + "example": "male"}, + "birthDate":{ "type": "number", + "example": "2-2-2024" + }, + "language" :{ "type": "string", + "example": "english" }, + "currency" :{ "type": "string", + "example": "USD" } + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "message": { "type": "string" } + } + } + } + } + } } }, "components": { @@ -580,10 +668,7 @@ }, "gender": { "type": "string", - "enum": [ - "male", - "female" - ], + "enum": ["male", "female"], "nullable": true }, "birthDate": { @@ -622,6 +707,13 @@ } } } + }, + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } } } -} \ No newline at end of file +}