diff --git a/.env.example b/.env.example index 82f690da..c6d88092 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ -port=8000 +PORT=8000 AUTH_SECRET= DB_USER= DB_HOST= DB_PASSWORD= -DB_NAME= \ No newline at end of file +DB_NAME= +NODE_ENV=development +SMTP_USER= +SMTP_PASSWORD= +SMTP_HOST= +SMTP_SERVICE= + diff --git a/db/migrations/1721611425287-migration.ts b/db/migrations/1721611425287-migration.ts new file mode 100644 index 00000000..7cac1745 --- /dev/null +++ b/db/migrations/1721611425287-migration.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1721611425287 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + } + + public async down(queryRunner: QueryRunner): Promise { + } + +} diff --git a/jest.config.ts b/jest.config.ts index 7c6f1fff..f06d2c63 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -4,10 +4,13 @@ const config: Config = { verbose: true, preset: "ts-jest", testEnvironment: "node", - globals: { - "ts-jest": { - tsconfig: "tsconfig.jest.json", - }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: "tsconfig.jest.json", + }, + ], }, }; diff --git a/package.json b/package.json index 2dce7113..2f03ab02 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,21 @@ "test": "jest ", "typeorm": "typeorm-ts-node-commonjs", "build": "tsc", - "prod": "node dist/index.js" + "prod": "node dist/index.js", + "migrate": "typeorm-ts-node-commonjs migration:run -d src/data-source", + "migration:create": "typeorm-ts-node-commonjs migration:create db/migrations/migration", + "migration:generate": "typeorm-ts-node-commonjs migration:generate db/migrations/migration -d src/data-source", + "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/data-source" + }, + "repository": { + "type": "git", + "url": "https://github.com/hngprojects/hng_boilerplate_expressjs" }, - "keywords": [], "author": "", "license": "ISC", + "bugs": { + "url": "https://github.com/hngprojects/hng_boilerplate_expressjs/issues" + }, "devDependencies": { "@types/express": "^4.17.21", "@types/jest": "^29.5.12", @@ -21,7 +31,7 @@ "@types/supertest": "^6.0.2", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "^5.5.3" }, "dependencies": { @@ -51,6 +61,7 @@ "swagger-ui-express": "^5.0.1", "ts-jest": "^29.2.3", "ts-node-dev": "^2.0.0", + "twilio": "^5.2.2", "typeorm": "^0.3.20" } } diff --git a/src/config/index.ts b/src/config/index.ts index 38ab1b1a..853e7f73 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -14,6 +14,11 @@ const config = { SMTP_PASSWORD: process.env.SMTP_PASSWORD, SMTP_HOST: process.env.SMTP_HOST, SMTP_SERVICE: process.env.SMTP_SERVICE, + NODE_ENV: process.env.NODE_ENV, + TWILIO_SID: process.env.TWILIO_SID, + TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN, + TWILIO_PHONE_NUMBER: process.env.TWILIO_PHONE_NUMBER, + }; export default config; diff --git a/src/controllers/HelpController.ts b/src/controllers/HelpController.ts new file mode 100644 index 00000000..2c25af3d --- /dev/null +++ b/src/controllers/HelpController.ts @@ -0,0 +1,64 @@ +// src/controllers/UserController.ts +import { Request, Response } from "express"; +import { HelpService } from "../services"; +import { HttpError } from "../middleware"; + +class HelpController { + private helpService: HelpService; + + constructor() { + this.helpService = new HelpService(); + } + + async createTopic(req: Request, res:Response):Promise { + try { + const topic = await this.helpService.create(req); + res.status(201).json({ + success: true, + message: 'Topic Created Successfully', + data: { + article_id: topic.id, + content: topic.content, + author: topic.author, + title: topic.title, + createdAt: topic.createdAt, + updatedAt: topic.updatedAt + }, + status_code: 201 + }); + } catch (error) { + if (error instanceof HttpError) { + res.status(error.status).json({message: error.message}); + } else { + res.status(500).json({ message: error.message || "Internal Server Error" }); + } + } + } + + async updateTopic(req: Request, res:Response):Promise { + try { + const topic = await this.helpService.update(req); + res.status(200).json({ + success: true, + message: 'Topic Updated Successfully', + data: { + article_id: topic.id, + content: topic.content, + author: topic.author, + title: topic.title, + createdAt: topic.createdAt, + updatedAt: topic.updatedAt + }, + status_code: 200 + }); + } catch (error) { + if (error instanceof HttpError) { + res.status(error.status).json({message: error.message}); + } else { + res.status(500).json({ message: error.message || "Internal Server Error" }); + } + } + } +} + +export default HelpController; \ No newline at end of file diff --git a/src/controllers/SmsController.ts b/src/controllers/SmsController.ts new file mode 100644 index 00000000..47bb2bc1 --- /dev/null +++ b/src/controllers/SmsController.ts @@ -0,0 +1,46 @@ +import { Request, Response } from "express"; +import SmsService from "../services/sms.services"; +import AppDataSource from "../data-source"; +import { User } from "../models"; + +export const sendSms = async (req: Request, res: Response): Promise => { + const { phone_number, message } = req.body; + const sender_id = req.user.id; + + if (!phone_number || !message || !sender_id) { + res.status(400).json({ + status: "unsuccessful", + status_code: 400, + message: + "Valid phone number, message content, and sender ID must be provided.", + }); + return; + } + + try { + const userRepository = AppDataSource.getRepository(User); + const sender = await userRepository.findOneBy({ id: sender_id }); + + if (!sender) { + res.status(404).json({ + status: "unsuccessful", + status_code: 404, + message: "Sender not found.", + }); + return; + } + + await SmsService.sendSms(sender, phone_number, message); + res.status(200).json({ + status: "success", + status_code: 200, + message: "SMS sent successfully.", + }); + } catch (error) { + res.status(500).json({ + status: "unsuccessful", + status_code: 500, + message: "Failed to send SMS. Please try again later.", + }); + } +}; diff --git a/src/controllers/TestimonialsController.ts b/src/controllers/TestimonialsController.ts index 429797f7..a1982b36 100644 --- a/src/controllers/TestimonialsController.ts +++ b/src/controllers/TestimonialsController.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { AppDataSource } from "../data-source"; +import AppDataSource from "../data-source"; import { Testimonial } from "../models/Testimonial"; export default class TestimonialsController { @@ -133,4 +133,43 @@ export default class TestimonialsController { res.status(500).send({ message: error.message }); } } + + // CODE BY TOMILLA OLUWAFEMI + public async getAllTestimonials(req: Request, res: Response) { + try { + const testimonials = await AppDataSource.getRepository(Testimonial).find(); + res.status(200).json({ + message: "Testimonials retrieved successfully", + status_code: 200, + data: testimonials, + }); + } catch (error) { + res.status(500).send({ message: error.message }); + } + } + + public async deleteTestimonial(req: Request, res: Response) { + try { + const { testimonial_id } = req.params; + + const testimonialToDelete = await AppDataSource.getRepository(Testimonial).findOne({ + where: { id: testimonial_id }, + }); + + if (!testimonialToDelete) { + return res + .status(404) + .send({ message: "Testimonial not found", status_code: 404 }); + } + + await AppDataSource.getRepository(Testimonial).remove(testimonialToDelete); + + res.status(200).json({ + message: "Testimonial deleted successfully", + status_code: 200, + }); + } catch (error) { + res.status(500).send({ message: error.message }); + } + } } diff --git a/src/controllers/productController.ts b/src/controllers/productController.ts index ee4fcd1a..41eb413c 100644 --- a/src/controllers/productController.ts +++ b/src/controllers/productController.ts @@ -27,7 +27,6 @@ export class ProductController { products: products.map((product) => ({ name: product.name, description: product.description, - price: product.price, category: product.category, })), pagination: { diff --git a/src/data-source.ts b/src/data-source.ts index 8ee275d6..5eea9741 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { DataSource, Tree } from 'typeorm'; import config from './config'; +const isDevelopment = config.NODE_ENV === 'development'; export const AppDataSource = new DataSource({ type: 'postgres', @@ -9,13 +10,16 @@ export const AppDataSource = new DataSource({ username: config.DB_USER, password: config.DB_PASSWORD, database: config.DB_NAME, - synchronize: true, + synchronize: isDevelopment, logging: false, entities: ['src/models/**/*.ts'], - // ssl: false, - // extra: { - // ssl: { - // rejectUnauthorized: false, - // }, - // }, }); + +export async function initializeDataSource() { + if (!AppDataSource.isInitialized) { + await AppDataSource.initialize(); + } + return AppDataSource; +} + +export default AppDataSource; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index d8c2d03b..58db3e26 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -7,6 +7,9 @@ import { ServerError, Unauthorized, } from "./error"; +import log from "../utils/logger"; +import jwt from "jsonwebtoken"; +import config from "../config"; export const authMiddleware = async ( req: Request, @@ -17,30 +20,41 @@ export const authMiddleware = async ( const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith("Bearer ")) { - throw new HttpError(400, "Bad Request"); + return res.status(401).json({ + status_code: "401", + message: "Invalid token", + }); } const token = authHeader.split(" ")[1]; if (!token) { - throw new Unauthorized("Invalid token"); + return res.status(401).json({ + status_code: "401", + message: "Invalid token", + }); } - const payload = verifyToken(token); - - if (!payload) { - throw new Unauthorized("Unauthroized"); - } - - const user = await User.findOne({ - where: { email: payload["email"] as string }, + jwt.verify(token, config.TOKEN_SECRET, async (err, decoded: any) => { + if (err) { + return res.status(401).json({ + status_code: "401", + message: "Invalid token", + }); + } + const user = await User.findOne({ + where: { email: decoded["email"] as string }, + }); + if (!user) { + return res.status(401).json({ + status_code: "401", + message: "Invalid token", + }); + } + req.user = user; + next(); }); - - if (!user) { - throw new ResourceNotFound("User not found"); - } - req.user = user; - next(); } catch (error) { + log.error(error); throw new ServerError("INTERNAL_SERVER_ERROR"); } }; diff --git a/src/models/product.ts b/src/models/product.ts index 1d99e94e..53e2ef23 100644 --- a/src/models/product.ts +++ b/src/models/product.ts @@ -13,9 +13,6 @@ export class Product extends ExtendedBaseEntity { @Column() description: string; - @Column() - price: number; - @Column() category: string; diff --git a/src/models/user.ts b/src/models/user.ts index 74a159a0..efa4d71f 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -71,9 +71,6 @@ export class User extends ExtendedBaseEntity { @JoinTable() organizations: Organization[]; - @OneToMany(() => Sms, (sms) => sms.sender, { cascade: true }) - sms: Sms[]; - @CreateDateColumn() createdAt: Date; diff --git a/src/routes/help-center.ts b/src/routes/help-center.ts new file mode 100644 index 00000000..3927a23d --- /dev/null +++ b/src/routes/help-center.ts @@ -0,0 +1,10 @@ +// src/routes/help-center.ts +import { Router } from "express"; +import HelpController from "../controllers/HelpController"; +import { authMiddleware, verifyAdmin } from "../services"; + +const helpRouter = Router(); +const helpController = new HelpController(); +helpRouter.post("/topics", authMiddleware, helpController.createTopic.bind(helpController)); +helpRouter.patch("/topics/:id", authMiddleware, helpController.updateTopic.bind(helpController)); +export { helpRouter }; \ No newline at end of file diff --git a/src/routes/sms.ts b/src/routes/sms.ts new file mode 100644 index 00000000..de9e1594 --- /dev/null +++ b/src/routes/sms.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { sendSms } from "../controllers/SmsController"; +import { authMiddleware } from "../middleware"; + +const smsRouter = Router(); + +smsRouter.post("/send", authMiddleware, sendSms); + +export { smsRouter }; diff --git a/src/routes/testimonial.ts b/src/routes/testimonial.ts index 127436fe..55cdc57f 100644 --- a/src/routes/testimonial.ts +++ b/src/routes/testimonial.ts @@ -19,4 +19,16 @@ testimonialRoute.get( testimonialController.getTestimonial.bind(testimonialController) ); +// CODE BY TOMILOLA OLUWAFEMI +testimonialRoute.get( + "/testimonials", + authMiddleware, + testimonialController.getAllTestimonials.bind(testimonialController) +); +testimonialRoute.delete( + "/testimonials/:testimonial_id", + authMiddleware, + testimonialController.deleteTestimonial.bind(testimonialController) +); + export { testimonialRoute }; diff --git a/src/seeder.ts b/src/seeder.ts index f4b1ffaf..bb733f0d 100644 --- a/src/seeder.ts +++ b/src/seeder.ts @@ -1,68 +1,69 @@ // src/seeder.ts -import { AppDataSource } from "./data-source"; -import { User, Organization, Product, Profile } from "./models"; -import log from "./utils/logger"; +import { AppDataSource } from './data-source'; +import { User, Organization, Product, Profile } from './models'; +import log from './utils/logger'; const seed = async () => { // Create first user const user1 = new User(); - user1.name = "John Doe"; - user1.email = "johndoe@example.com"; - user1.password = "password"; + user1.name = 'John Doe'; + user1.email = 'johndoe@example.com'; + user1.password = 'password'; user1.otp = Math.floor(Math.random() * 10000); // Generate a random OTP user1.otp_expires_at = new Date(Date.now() + 3600 * 1000); // OTP expires in 1 hour user1.profile = new Profile(); - user1.profile.first_name = "John"; - user1.profile.last_name = "Doe"; - user1.profile.phone = "1234567890"; - user1.profile.avatarUrl = "http://example.com/avatar.jpg"; + user1.profile.first_name = 'John'; + user1.profile.last_name = 'Doe'; + user1.profile.phone = '1234567890'; + user1.profile.avatarUrl = 'http://example.com/avatar.jpg'; // Create second user const user2 = new User(); - user2.name = "Jane Doe"; - user2.email = "janedoe@example.com"; - user2.password = "password"; + user2.name = 'Jane Doe'; + user2.email = 'janedoe@example.com'; + user2.password = 'password'; user2.otp = Math.floor(Math.random() * 10000); // Generate a random OTP user2.otp_expires_at = new Date(Date.now() + 3600 * 1000); // OTP expires in 1 hour user2.profile = new Profile(); - user2.profile.first_name = "Jane"; - user2.profile.last_name = "Doe"; - user2.profile.phone = "0987654321"; - user2.profile.avatarUrl = "http://example.com/avatar.jpg"; + user2.profile.first_name = 'Jane'; + user2.profile.last_name = 'Doe'; + user2.profile.phone = '0987654321'; + user2.profile.avatarUrl = 'http://example.com/avatar.jpg'; // Create products const product1 = new Product(); - product1.name = "Product 1"; - product1.description = "Description for product 1"; + product1.name = 'Product 1'; + product1.description = 'Description for product 1'; product1.user = user1; const product2 = new Product(); - product2.name = "Product 2"; - product2.description = "Description for product 2"; + product2.name = 'Product 2'; + product2.description = 'Description for product 2'; product2.user = user1; const product3 = new Product(); - product3.name = "Product 3"; - product3.description = "Description for product 3"; + product3.name = 'Product 3'; + product3.description = 'Description for product 3'; product3.user = user2; const product4 = new Product(); - product4.name = "Product 4"; - product4.description = "Description for product 4"; + product4.name = 'Product 4'; + product4.description = 'Description for product 4'; + product4.user = user2; // Create organizations const organization1 = new Organization(); - organization1.name = "Org 1"; - organization1.description = "Description for org 1"; + organization1.name = 'Org 1'; + organization1.description = 'Description for org 1'; const organization2 = new Organization(); - organization2.name = "Org 2"; - organization2.description = "Description for org 2"; + organization2.name = 'Org 2'; + organization2.description = 'Description for org 2'; const organization3 = new Organization(); - organization3.name = "Org 3"; - organization3.description = "Description for org 3"; + organization3.name = 'Org 3'; + organization3.description = 'Description for org 3'; // Assign organizations to users user1.organizations = [organization1, organization2]; @@ -79,7 +80,7 @@ const seed = async () => { await AppDataSource.manager.save(product3); await AppDataSource.manager.save(product4); - log.info("Seeding completed successfully."); + log.info('Seeding completed successfully.'); }; export { seed }; diff --git a/src/services/OrgService.ts b/src/services/OrgService.ts index 5d363299..e460c33d 100644 --- a/src/services/OrgService.ts +++ b/src/services/OrgService.ts @@ -1,12 +1,12 @@ import { Organization } from "../models/organization"; -import { AppDataSource } from "../data-source"; +import AppDataSource from "../data-source"; import { User } from "../models/user"; import { IOrgService, IUserService } from "../types"; export class OrgService implements IOrgService { public async removeUser( org_id: string, - user_id: string, + user_id: string ): Promise { const userRepository = AppDataSource.getRepository(User); const organizationRepository = AppDataSource.getRepository(Organization); @@ -29,7 +29,7 @@ export class OrgService implements IOrgService { // Check if the user is part of the organization const userInOrganization = organization.users.some( - (user) => user.id === user_id, + (user) => user.id === user_id ); if (!userInOrganization) { return null; @@ -37,7 +37,7 @@ export class OrgService implements IOrgService { // Remove the user from the organization organization.users = organization.users.filter( - (user) => user.id !== user_id, + (user) => user.id !== user_id ); await organizationRepository.save(organization); diff --git a/src/services/auth.services.ts b/src/services/auth.services.ts index 93cd3b53..a27bf008 100644 --- a/src/services/auth.services.ts +++ b/src/services/auth.services.ts @@ -1,4 +1,4 @@ -import { AppDataSource } from "../data-source"; +import AppDataSource from "../data-source"; import { Profile, User } from "../models"; import { IAuthService, IUserSignUp, IUserLogin } from "../types"; import { Conflict, HttpError } from "../middleware"; diff --git a/src/services/help.services.ts b/src/services/help.services.ts new file mode 100644 index 00000000..f7dfa89a --- /dev/null +++ b/src/services/help.services.ts @@ -0,0 +1,126 @@ +// src/services/HelpService.ts +import { NextFunction, Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import { HelpCenterTopic } from "../models"; +import { User } from "../models"; +import { AppDataSource } from "../data-source"; +import { HttpError } from "../middleware"; +import config from "../config"; + + +export class HelpService { + + public async create (req:Request): Promise { + try { + const { title, content, author} = req.body; + + //Validate Input + if (!title || !content || !author) { + throw new HttpError(422, 'Validation failed: Title, content, and author are required'); + }; + + //Check for Existing Title + const articleRepository = AppDataSource.getRepository(HelpCenterTopic); + const existingTitle = await articleRepository.findOne({ where: { title }}); + if (existingTitle) { + throw new HttpError(422, 'Article already exists'); + }; + + const articleEntity = articleRepository.create({title, content, author}); + const article = await articleRepository.save(articleEntity); + return article; + + } catch (error) { + throw new HttpError(error.status || 500, error.message || error); + } + }; + + public async update (req:Request):Promise{ + try { + const { title, content, author} = req.body; + const article_id = req.params.id; + + //Get article repo + const articleRepository = AppDataSource.getRepository(HelpCenterTopic); + + // Check if article exists + const existingArticle = await articleRepository.findOne({ where: { id: article_id } }); + + if (!existingArticle) { + throw new HttpError(404, 'Not Found'); + }; + + //Update Article on DB + await articleRepository.update(article_id, {title, content, author}); + + //Fetch Updated article + const newArticle = await articleRepository.findOne({ where: { id: article_id } }); + return newArticle; + + } catch (error) { + console.error(error); + throw new HttpError(error.status || 500, error.message || error); + }; + } + +} + +export const authMiddleware = (req:Request, res:Response, next:NextFunction) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json( + { + success: false, + message: 'Access denied. No token provided', + status_code: 401 + }); + } + + try { + const verified = jwt.verify(token, config.TOKEN_SECRET); + if (verified){ + next(); + } + } catch (error) { + res.status(401).json( + { + success: false, + message: 'Access denied. Invalid token', + status_code: 401 + }); + } +}; + +export const verifyAdmin = async (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + try { + const decodedToken = jwt.decode(token); + + if (typeof decodedToken === 'string' || !decodedToken) { + return res.status(401).json({ + success: false, + message: 'Access denied. Invalid token', + status_code: 401 + }); + } + + const userRepository = AppDataSource.getRepository(User); + const user = await userRepository.findOne({ where: { id: decodedToken.userId } }); + console.log(user.role); + + if (user.role !== 'admin' ) { + return res.status(403).json({ + success: false, + message: "Access denied! You are not an admin", + status_code: 403 + }); + } + + next(); + } catch (error) { + res.status(401).json({ status: 'error', message: 'Access denied. Invalid token' }); + } +}; \ No newline at end of file diff --git a/src/services/sms.services.ts b/src/services/sms.services.ts new file mode 100644 index 00000000..5e8cd52a --- /dev/null +++ b/src/services/sms.services.ts @@ -0,0 +1,35 @@ +import { Twilio } from "twilio"; +import config from "../config"; +import AppDataSource from "../data-source"; +import { Sms } from "../models/sms"; +import { User } from "../models"; + +class SmsService { + private twilioClient: Twilio; + + constructor() { + this.twilioClient = new Twilio(config.TWILIO_SID, config.TWILIO_AUTH_TOKEN); + } + + public async sendSms( + sender: User, + phoneNumber: string, + message: string + ): Promise { + await this.twilioClient.messages.create({ + body: message, + from: config.TWILIO_PHONE_NUMBER, + to: phoneNumber, + }); + + const sms = new Sms(); + sms.sender = sender; + sms.phone_number = phoneNumber; + sms.message = message; + + const smsRepository = AppDataSource.getRepository(Sms); + await smsRepository.save(sms); + } +} + +export default new SmsService(); diff --git a/src/test/auth.spec.ts b/src/test/auth.spec.ts index 39400ba9..36599ea6 100644 --- a/src/test/auth.spec.ts +++ b/src/test/auth.spec.ts @@ -1,15 +1,21 @@ // @ts-nocheck -import { AppDataSource } from "../data-source"; +import AppDataSource from "../data-source"; import { User } from "../models"; import { hashPassword, generateNumericOTP, comparePassword } from "../utils"; import { Sendmail } from "../utils/mail"; import jwt from "jsonwebtoken"; -import config from "../config"; import { Conflict, HttpError } from "../middleware"; import { AuthService } from "../services"; -jest.mock("../data-source"); +jest.mock("../data-source", () => { + return { + AppDataSource: { + manager: {}, + initialize: jest.fn().mockResolvedValue(true), + }, + }; +}); jest.mock("../models"); jest.mock("../utils"); jest.mock("../utils/mail"); @@ -17,9 +23,17 @@ jest.mock("jsonwebtoken"); describe("AuthService", () => { let authService: AuthService; + let mockManager; beforeEach(() => { authService = new AuthService(); + + mockManager = { + save: jest.fn(), + }; + + // Assign the mock manager to the AppDataSource.manager + AppDataSource.manager = mockManager; }); describe("signUp", () => { @@ -54,7 +68,7 @@ describe("AuthService", () => { (User.findOne as jest.Mock).mockResolvedValue(null); (hashPassword as jest.Mock).mockResolvedValue(hashedPassword); (generateNumericOTP as jest.Mock).mockReturnValue(otp); - (AppDataSource.manager.save as jest.Mock).mockResolvedValue(createdUser); + mockManager.save.mockResolvedValue(createdUser); (jwt.sign as jest.Mock).mockReturnValue(token); (Sendmail as jest.Mock).mockResolvedValue(mailSent); @@ -108,7 +122,7 @@ describe("AuthService", () => { (jwt.verify as jest.Mock).mockReturnValue({ userId: 1 }); (User.findOne as jest.Mock).mockResolvedValue(user); - (AppDataSource.manager.save as jest.Mock).mockResolvedValue(user); + mockManager.save.mockResolvedValue(user); const result = await authService.verifyEmail(token, otp); diff --git a/src/test/testimonial.spec.ts b/src/test/testimonial.spec.ts deleted file mode 100644 index 6b6c05dc..00000000 --- a/src/test/testimonial.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -// @ts-nocheck - -import { Request, Response } from "express"; -import { AppDataSource } from "../data-source"; -import { Testimonial } from "../models/Testimonial"; -import TestimonialsController from "../controllers/TestimonialsController"; - -jest.mock("../data-source", () => ({ - AppDataSource: { - getRepository: jest.fn(), - }, -})); - -describe("TestimonialsController", () => { - let testimonialsController: TestimonialsController; - let mockRequest: Partial; - let mockResponse: Partial; - let mockRepository: any; - - beforeEach(() => { - testimonialsController = new TestimonialsController(); - mockRequest = {}; - mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - send: jest.fn(), - }; - mockRepository = { - create: jest.fn(), - save: jest.fn(), - findOne: jest.fn(), - }; - - (AppDataSource.getRepository as jest.Mock).mockReturnValue(mockRepository); - }); - - describe("createTestimonial", () => { - it("should create a new testimonial", async () => { - const testimonialData = { - client_name: "Client Name", - client_position: "Client Position", - testimonial: "This is a testimonial.", - }; - - mockRequest.body = testimonialData; - (mockRequest as any).user = { id: 1 }; - - const testimonialInstance = { id: 1, ...testimonialData, user_id: 1 }; - mockRepository.create.mockReturnValue(testimonialInstance); - mockRepository.save.mockResolvedValue(testimonialInstance); - - await testimonialsController.createTestimonial( - mockRequest as Request, - mockResponse as Response - ); - - expect(mockRepository.create).toHaveBeenCalledWith({ - user_id: 1, - ...testimonialData, - }); - expect(mockRepository.save).toHaveBeenCalledWith(testimonialInstance); - expect(mockResponse.status).toHaveBeenCalledWith(201); - expect(mockResponse.json).toHaveBeenCalledWith({ - message: "Testimonial created successfully", - status_code: 201, - data: testimonialInstance, - }); - }); - }); - - describe("getTestimonial", () => { - it("should retrieve a testimonial by ID", async () => { - const testimonialId = "1"; - const testimonialInstance = { - id: testimonialId, - client_name: "Client Name", - client_position: "Client Position", - testimonial: "This is a testimonial.", - user_id: 1, - }; - - mockRequest.params = { testimonial_id: testimonialId }; - mockRepository.findOne.mockResolvedValue(testimonialInstance); - - await testimonialsController.getTestimonial( - mockRequest as Request, - mockResponse as Response - ); - - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { id: testimonialId }, - }); - expect(mockResponse.status).toHaveBeenCalledWith(200); - expect(mockResponse.json).toHaveBeenCalledWith({ - message: "Testimonial retrieved successfully", - status_code: 200, - data: testimonialInstance, - }); - }); - - it("should return 404 if testimonial not found", async () => { - const testimonialId = "1"; - - mockRequest.params = { testimonial_id: testimonialId }; - mockRepository.findOne.mockResolvedValue(null); - - await testimonialsController.getTestimonial( - mockRequest as Request, - mockResponse as Response - ); - - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { id: testimonialId }, - }); - expect(mockResponse.status).toHaveBeenCalledWith(404); - expect(mockResponse.send).toHaveBeenCalledWith({ - message: "Testimonial not found", - status_code: 404, - }); - }); - - it("should handle errors", async () => { - const testimonialId = "1"; - const errorMessage = "Internal Server Error"; - - mockRequest.params = { testimonial_id: testimonialId }; - mockRepository.findOne.mockRejectedValue(new Error(errorMessage)); - - await testimonialsController.getTestimonial( - mockRequest as Request, - mockResponse as Response - ); - - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { id: testimonialId }, - }); - expect(mockResponse.status).toHaveBeenCalledWith(500); - expect(mockResponse.send).toHaveBeenCalledWith({ - message: errorMessage, - }); - }); - }); -}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 0c960a5b..13b0ae2c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,7 @@ import * as bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import config from "../config"; +import { Unauthorized } from "../middleware"; export const getIsInvalidMessage = (fieldLabel: string) => `${fieldLabel} is invalid`; @@ -37,6 +38,8 @@ export const verifyToken = (token: string): Record | null => { const payload = jwt.verify(token, config.TOKEN_SECRET); return payload as Record; } catch (error) { - throw new Error("Invalid token"); + return { + error: error.message, + }; } }; diff --git a/tsconfig.json b/tsconfig.json index acbdc47e..15f83032 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,10 @@ { "compilerOptions": { "lib": ["es5", "es6"], - "target": "ES2020", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "target": "ES2021", + "module": "commonjs", + "removeComments": true, + "allowSyntheticDefaultImports": true, "outDir": "./build", "emitDecoratorMetadata": true, "experimentalDecorators": true, @@ -13,5 +14,6 @@ "skipLibCheck": true, "typeRoots": ["src/types"] }, - "include": ["src/**/*", "config"] + "include": ["src/**/*", "config"], + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } diff --git a/yarn.lock b/yarn.lock index f0e9fc9c..ff17d662 100644 --- a/yarn.lock +++ b/yarn.lock @@ -940,6 +940,13 @@ acorn@^8.11.0, acorn@^8.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" @@ -1041,6 +1048,15 @@ atomic-sleep@^1.0.0: resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== +axios@^1.6.8: + version "1.7.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" + integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -1487,7 +1503,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4: version "4.3.5" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== @@ -1828,6 +1844,11 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + foreground-child@^3.1.0: version "3.2.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7" @@ -2047,6 +2068,14 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -3258,6 +3287,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -3278,7 +3312,7 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" -qs@^6.11.0: +qs@^6.11.0, qs@^6.9.4: version "6.12.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.3.tgz#e43ce03c8521b9c7fd7f1f13e514e5ca37727754" integrity sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ== @@ -3396,6 +3430,11 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +scmp@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/scmp/-/scmp-2.1.0.tgz#37b8e197c425bdeb570ab91cc356b311a11f9c9a" + integrity sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q== + secure-json-parse@^2.4.0: version "2.7.0" resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" @@ -3813,7 +3852,7 @@ ts-node-dev@^2.0.0: ts-node "^10.4.0" tsconfig "^7.0.0" -ts-node@^10.4.0, ts-node@^10.9.1: +ts-node@^10.4.0, ts-node@^10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -3847,6 +3886,19 @@ tslib@^2.5.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +twilio@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/twilio/-/twilio-5.2.2.tgz#56285174e72fcfdbe28d4ca021337910c3d8191b" + integrity sha512-t2Nd8CvqAc0YxbJghKYQl1Vxc7e6SrWk4U28wwkarUohGcsUMLsGpYeGXKw1Va0KB9TGVZYCs8dcP4TdLJUN9Q== + dependencies: + axios "^1.6.8" + dayjs "^1.11.9" + https-proxy-agent "^5.0.0" + jsonwebtoken "^9.0.2" + qs "^6.9.4" + scmp "^2.1.0" + xmlbuilder "^13.0.2" + type-detect@4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -4007,6 +4059,11 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" +xmlbuilder@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-13.0.2.tgz#02ae33614b6a047d1c32b5389c1fdacb2bce47a7" + integrity sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ== + xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"