├── .gitignore ├── src ├── types │ ├── jwtPayload.ts │ ├── User.ts │ ├── RequestValidators.ts │ └── Rental.ts ├── database │ ├── entities │ │ ├── index.ts │ │ ├── user.ts │ │ ├── customer.ts │ │ ├── genre.ts │ │ ├── rental.ts │ │ └── movie.ts │ └── connect.ts ├── middleware │ ├── isAdmin.ts │ ├── errorHandler.ts │ ├── isAuth.ts │ └── validateResource.ts ├── utils │ ├── logger.ts │ ├── rentalPrice.ts │ ├── token.ts │ └── isValidId.ts ├── routes │ ├── user.ts │ ├── rental.ts │ ├── movie.ts │ ├── genre.ts │ └── customer.ts ├── validators │ ├── genre.ts │ ├── rental.ts │ ├── user.ts │ ├── customer.ts │ └── movie.ts ├── server.ts └── controllers │ ├── genre.ts │ ├── user.ts │ ├── movie.ts │ ├── customer.ts │ └── rental.ts ├── vitest.config.ts ├── config ├── example.json └── default.json ├── __tests__ ├── index.test.ts ├── utils │ ├── rentalPrice.test.ts │ ├── token.test.ts │ └── isValidId.test.ts └── controllers │ ├── genre.test.ts │ ├── user.test.ts │ ├── customer.test.ts │ └── movie.test.ts ├── tsconfig.json ├── package.json └── ERD.drawio /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | config/default.json 3 | coverage/ -------------------------------------------------------------------------------- /src/types/jwtPayload.ts: -------------------------------------------------------------------------------- 1 | export type jwtPayload = { 2 | _id: number; 3 | isAdmin?: boolean; 4 | }; 5 | -------------------------------------------------------------------------------- /src/types/User.ts: -------------------------------------------------------------------------------- 1 | import { registerInput } from "../validators/user"; 2 | 3 | export type UserType = Omit; 4 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test:{ 5 | clearMocks: true, 6 | } 7 | }); -------------------------------------------------------------------------------- /config/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "PG_USER": "", 3 | "PG_HOST": "", 4 | "PG_DATABASE": "", 5 | "PG_PORT": 0, 6 | "PG_PASSWORD": "", 7 | "JWT_SECRET": "" 8 | } 9 | -------------------------------------------------------------------------------- /__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, expect} from 'vitest'; 2 | 3 | describe("example",()=>{ 4 | it("should pass",()=>{ 5 | expect(1).toBe(1); 6 | }) 7 | }) -------------------------------------------------------------------------------- /src/database/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./user"; 2 | export * from "./customer"; 3 | export * from "./genre"; 4 | export * from "./movie"; 5 | export * from "./rental"; 6 | -------------------------------------------------------------------------------- /src/types/RequestValidators.ts: -------------------------------------------------------------------------------- 1 | import { AnyZodObject } from "zod"; 2 | 3 | export type RequestValidators = { 4 | body?: AnyZodObject; 5 | params?: AnyZodObject; 6 | query?: AnyZodObject; 7 | }; 8 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "PG_USER": "postgres", 3 | "PG_HOST": "localhost", 4 | "PG_DATABASE": "vidly", 5 | "PG_PORT": 5432, 6 | "PG_PASSWORD": "ahmed", 7 | "JWT_SECRET":"this is jwt secret", 8 | "PORT":3000 9 | } 10 | -------------------------------------------------------------------------------- /src/middleware/isAdmin.ts: -------------------------------------------------------------------------------- 1 | import { Response, NextFunction } from "express"; 2 | export const isAdmin = ({}, res: Response, next: NextFunction) => { 3 | if (!res.locals.user.isAdmin) return res.status(403).send("forbiden"); 4 | return next(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/middleware/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler } from "express"; 2 | import { log } from "../utils/logger"; 3 | export const errorHandlerMiddleware: ErrorRequestHandler = ( 4 | err, 5 | req, 6 | res 7 | ) => { 8 | log.error(err); 9 | return res.status(500).send("something went wrong"); 10 | }; 11 | -------------------------------------------------------------------------------- /src/types/Rental.ts: -------------------------------------------------------------------------------- 1 | import { createRentalInput } from "../validators/rental"; 2 | import { createCustomerInput } from "../validators/customer"; 3 | import { createMovieInput } from "../validators/movie"; 4 | 5 | export type RentalType = createRentalInput & { 6 | dateout: Date; 7 | datereturned: Date; 8 | rentalFee: number; 9 | } & createCustomerInput & 10 | createMovieInput; 11 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from "winston"; 2 | 3 | export const log = createLogger({ 4 | format: format.combine( 5 | format.colorize(), 6 | format.timestamp(), 7 | format.printf(({ timestamp, level, message }) => { 8 | return `[${timestamp}] ${level} : ${message}`; 9 | }) 10 | ), 11 | transports: [new transports.Console()], 12 | }); 13 | -------------------------------------------------------------------------------- /src/middleware/isAuth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { verifyToken } from "../utils/token"; 3 | 4 | export const isAuth = (req: Request, res: Response, next: NextFunction) => { 5 | const token = req.header("x-auth-token"); 6 | if (!token) return res.status(401).send("no token provided"); 7 | try { 8 | res.locals.user = verifyToken(token); 9 | return next(); 10 | } catch (ex) { 11 | return res.status(400).send(ex.message); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/rentalPrice.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | export const rentalPrice = function ( 3 | dateOut: Date, 4 | dailyRentalRate: number 5 | ): number { 6 | // this function to calculate the price of rental when the movie is returned 7 | 8 | const rentalDay = moment().diff(dateOut, "days"); //calculate the nOf days when the movie is rentaled untill now"time it returned" 9 | const rentalFee = rentalDay * dailyRentalRate; //calculate the price 10 | return rentalFee as number; 11 | }; 12 | -------------------------------------------------------------------------------- /src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { loginController, registerController } from "../controllers/user"; 3 | import validateResource from "../middleware/validateResource"; 4 | import { registerSchema, loginSchema } from "../validators/user"; 5 | const router = Router(); 6 | 7 | router.post( 8 | "/register", 9 | validateResource({ body: registerSchema }), 10 | registerController 11 | ); 12 | router.post("/login", validateResource({ body: loginSchema }), loginController); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /src/utils/token.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import config from "config"; 3 | import { jwtPayload } from "../types/jwtPayload"; 4 | 5 | 6 | export const createToken = (payload: jwtPayload) => { 7 | const JWT_SECRET = config.get("JWT_SECRET"); 8 | return jwt.sign(payload, JWT_SECRET, { expiresIn: "15m" }); 9 | }; 10 | 11 | export const verifyToken = (token: string) => { 12 | try { 13 | const JWT_SECRET = config.get("JWT_SECRET"); 14 | return jwt.verify(token, JWT_SECRET); 15 | } catch (error) { 16 | throw new Error("invalid Token Provided"); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/rental.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | getRentalsContrller, 4 | addRentalController, 5 | backRentalController, 6 | } from "../controllers/rental"; 7 | import validateResource from "../middleware/validateResource"; 8 | import { createRentalSchema } from "../validators/rental"; 9 | const router = Router(); 10 | 11 | router.get("/", getRentalsContrller); 12 | 13 | router.post( 14 | "/", 15 | validateResource({ 16 | body: createRentalSchema, 17 | }), 18 | addRentalController 19 | ); 20 | 21 | router.put("/back/:rentalId", backRentalController); 22 | 23 | export default router; 24 | -------------------------------------------------------------------------------- /src/database/connect.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import config from "config"; 3 | import { User, Customer, Genre, Movie, Rental } from "./entities"; 4 | 5 | export default new DataSource({ 6 | type: "postgres", 7 | username: config.get("PG_USER"), 8 | host: config.get("PG_HOST"), 9 | database: config.get("PG_DATABASE"), 10 | port: config.get("PG_PORT"), 11 | password: config.get("PG_PASSWORD"), 12 | synchronize: process.env.NODE_ENV !== "production", 13 | logging: process.env.NODE_ENV !== "production", 14 | entities: [User, Customer, Movie, Genre, Rental], 15 | }); 16 | -------------------------------------------------------------------------------- /src/validators/genre.ts: -------------------------------------------------------------------------------- 1 | import { isValidGenreId } from "../utils/isValidId"; 2 | import zod from "zod"; 3 | 4 | export const create_updateGenreSchema = zod.object({ 5 | name: zod 6 | .string({ 7 | required_error: "genre name is required", 8 | }) 9 | .min(3, "genre name too short - length should be greater than 3"), 10 | }); 11 | 12 | export const validGenreIdParam = zod.object({ 13 | id: zod 14 | .string({ 15 | required_error: "genre id parameter is required", 16 | }) 17 | .refine(isValidGenreId, (val) => ({ message: `${val} invalid genreId` })), 18 | }); 19 | 20 | export type create_updateGenreInput = zod.infer; 21 | 22 | -------------------------------------------------------------------------------- /src/validators/rental.ts: -------------------------------------------------------------------------------- 1 | import zod from "zod"; 2 | import { isValidMovieId, isValidCustomerId } from "../utils/isValidId"; 3 | 4 | export const createRentalSchema = zod.object({ 5 | customerId: zod 6 | .string({ 7 | required_error: "customer id is required", 8 | }) 9 | .refine(isValidCustomerId, (value) => ({ 10 | message: `${value} is invalid customer id`, 11 | })), 12 | movieId: zod 13 | .string({ 14 | required_error: "movie id is required", 15 | }) 16 | .refine(isValidMovieId, (value) => ({ 17 | message: `${value} is invalid movie id`, 18 | })), 19 | }); 20 | 21 | export type createRentalInput = zod.infer; 22 | -------------------------------------------------------------------------------- /__tests__/utils/rentalPrice.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, vi, it, expect, Mock } from "vitest"; 2 | import { rentalPrice } from "../../src/utils/rentalPrice"; 3 | 4 | describe("rentalPrice", () => { 5 | vi.setSystemTime(new Date('2024-10-31')); 6 | it("should return 0 if daysRented is 0", () => { 7 | const result = rentalPrice(new Date('2024-10-31'), 2); 8 | expect(result).toBe(0); 9 | }); 10 | 11 | it("should return 2 if daysRented is 1", () => { 12 | const result = rentalPrice(new Date('2024-10-30'), 2); 13 | expect(result).toBe(2); 14 | }); 15 | 16 | it("should return 6 if daysRented is 2 and daily rental rate is 3 - 2*3", () => { 17 | const result = rentalPrice(new Date('2024-10-29'), 3); 18 | expect(result).toBe(6); 19 | }); 20 | 21 | }); -------------------------------------------------------------------------------- /src/utils/isValidId.ts: -------------------------------------------------------------------------------- 1 | import { Customer, Genre, Movie } from "../database/entities"; 2 | 3 | export const isValidGenreId = async (genreId: string) => { 4 | const id: number = parseInt(genreId as string); 5 | const genre = await Genre.findOne({ where: { genre_id: id } }); 6 | return genre ? true : false; 7 | }; 8 | 9 | export const isValidMovieId = async (movie_id: string) => { 10 | const id: number = parseInt(movie_id as string); 11 | const movie = await Movie.findOne({ where: { movie_id:id } }); 12 | return movie ? true : false; 13 | }; 14 | 15 | export const isValidCustomerId = async (customerId: string) => { 16 | const id: number = parseInt(customerId as string); 17 | const customer = await Customer.findOne({ where: { customer_id: id } }); 18 | return customer ? true : false; 19 | }; 20 | -------------------------------------------------------------------------------- /src/database/entities/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | UpdateDateColumn, 6 | Entity, 7 | PrimaryGeneratedColumn, 8 | } from "typeorm"; 9 | 10 | @Entity("users") 11 | export class User extends BaseEntity { 12 | @PrimaryGeneratedColumn() 13 | user_id: number; 14 | 15 | @Column({ type: "varchar", length: 255, unique: true, nullable: false }) 16 | name: string; 17 | 18 | @Column({ type: "varchar", length: 255, unique: true, nullable: false }) 19 | email: string; 20 | 21 | @Column({ type: "text", nullable: false }) 22 | password: string; 23 | 24 | @Column({ type: "boolean", default: false }) 25 | isAdmin: boolean; 26 | 27 | @CreateDateColumn() 28 | createdAt: Date; 29 | 30 | @UpdateDateColumn() 31 | updatedAt: Date; 32 | } 33 | -------------------------------------------------------------------------------- /src/middleware/validateResource.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import {RequestValidators} from "../types/RequestValidators"; 3 | 4 | const validate = 5 | (validators: RequestValidators) => 6 | async (req: Request, res: Response, next: NextFunction) => { 7 | try { 8 | if (validators.params) { 9 | req.params = await validators.params.parseAsync(req.params); 10 | } 11 | if (validators.body) { 12 | req.body = await validators.body.parseAsync(req.body); 13 | } 14 | if (validators.query) { 15 | req.query = await validators.query.parseAsync(req.query); 16 | } 17 | return next(); 18 | } catch (error: any) { 19 | return res.status(400).send(error); 20 | } 21 | }; 22 | 23 | export default validate; 24 | -------------------------------------------------------------------------------- /src/validators/user.ts: -------------------------------------------------------------------------------- 1 | import zod from "zod"; 2 | 3 | export const loginSchema = zod.object({ 4 | email: zod 5 | .string({ 6 | required_error: "user email is requird", 7 | }) 8 | .min(5, "email too short -length must between 5 and 255") 9 | .max(255, "email too long -length must between 5 and 255"), 10 | password: zod.string({ 11 | required_error: "user password is required", 12 | }), 13 | }); 14 | 15 | export const registerSchema = loginSchema.extend({ 16 | name: zod 17 | .string({ 18 | required_error: "user name is required", 19 | }) 20 | .min(5, "name too short -length must between 5 and 50") 21 | .max(50, "name too long -length must between 5 and 50"), 22 | }); 23 | 24 | export type loginInput = zod.infer; 25 | export type registerInput = zod.infer; 26 | -------------------------------------------------------------------------------- /src/database/entities/customer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | UpdateDateColumn, 6 | Entity, 7 | PrimaryGeneratedColumn, 8 | OneToMany, 9 | } from "typeorm"; 10 | import { Rental } from "./rental"; 11 | 12 | @Entity("customers") 13 | export class Customer extends BaseEntity { 14 | @PrimaryGeneratedColumn() 15 | customer_id: number; 16 | 17 | @Column({ type: "varchar", length: 255, unique: true, nullable: false }) 18 | name: string; 19 | 20 | @Column({ type: "boolean", default: false }) 21 | isGold: boolean; 22 | 23 | @Column({ type: "text", nullable: false }) 24 | phone: string; 25 | 26 | @CreateDateColumn() 27 | createdAt: Date; 28 | 29 | @UpdateDateColumn() 30 | updatedAt: Date; 31 | 32 | @OneToMany(() => Rental,(rental)=>rental.customer) 33 | rentals: Rental[]; 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "skipLibCheck": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "moduleResolution": "node", 10 | "removeComments": true, 11 | "noImplicitAny": true, 12 | "strict": false, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "noImplicitThis": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "allowSyntheticDefaultImports": true, 21 | "esModuleInterop": true, 22 | "emitDecoratorMetadata": true, 23 | "experimentalDecorators": true, 24 | "resolveJsonModule": true, 25 | "baseUrl": "." 26 | }, 27 | "exclude": ["node_modules"], 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/routes/movie.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | createMovieSchema, 4 | updateMovieSchema, 5 | validMovieIdParam, 6 | } from "../validators/movie"; 7 | import { 8 | getMovieController, 9 | createMovieController, 10 | updateMovieController, 11 | deleteMovieController, 12 | } from "../controllers/movie"; 13 | 14 | import validateResource from "../middleware/validateResource"; 15 | const router = Router(); 16 | 17 | router.get("/:id", getMovieController); 18 | router.post( 19 | "/", 20 | validateResource({ body: createMovieSchema }), 21 | createMovieController 22 | ); 23 | router.put( 24 | "/:id", 25 | validateResource({ 26 | body: updateMovieSchema, 27 | params: validMovieIdParam, 28 | }), 29 | updateMovieController 30 | ); 31 | router.delete( 32 | "/:id", 33 | validateResource({ params: validMovieIdParam }), 34 | deleteMovieController 35 | ); 36 | 37 | export default router; 38 | -------------------------------------------------------------------------------- /src/database/entities/genre.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | UpdateDateColumn, 6 | Entity, 7 | PrimaryGeneratedColumn, 8 | JoinTable, 9 | ManyToMany, 10 | } from "typeorm"; 11 | import { Movie } from "./index"; 12 | 13 | @Entity("genres") 14 | export class Genre extends BaseEntity { 15 | @PrimaryGeneratedColumn() 16 | genre_id: number; 17 | 18 | @Column({ type: "varchar", length: 255 }) 19 | name: string; 20 | 21 | @ManyToMany(() => Movie, (movie) => movie.genres) 22 | @JoinTable({ 23 | name: "movies_genres", 24 | joinColumn: { 25 | name: "genre", 26 | referencedColumnName: "genre_id", 27 | }, 28 | inverseJoinColumn: { 29 | name: "movie", 30 | referencedColumnName: "movie_id", 31 | }, 32 | }) 33 | movies: Movie[]; 34 | 35 | @CreateDateColumn() 36 | createdAt: Date; 37 | 38 | @UpdateDateColumn() 39 | updatedAt: Date; 40 | } 41 | -------------------------------------------------------------------------------- /src/routes/genre.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | create_updateGenreSchema, 4 | validGenreIdParam, 5 | } from "../validators/genre"; 6 | import ValidateResource from "../middleware/validateResource"; 7 | import { 8 | getGenreController, 9 | createGenreController, 10 | updateGenreController, 11 | deleteGenreController, 12 | } from "../controllers/genre"; 13 | 14 | const router = Router(); 15 | 16 | router.get("/:id", getGenreController); 17 | 18 | router.post( 19 | "/", 20 | ValidateResource({ body: create_updateGenreSchema }), 21 | createGenreController 22 | ); 23 | 24 | router.put( 25 | "/:id", 26 | ValidateResource({ 27 | body: create_updateGenreSchema, 28 | params: validGenreIdParam, 29 | }), 30 | updateGenreController 31 | ); 32 | 33 | router.delete( 34 | "/:id", 35 | ValidateResource({ params: validGenreIdParam }), 36 | deleteGenreController 37 | ); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /src/database/entities/rental.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | ManyToOne, 8 | JoinColumn, 9 | } from "typeorm"; 10 | import { Customer } from "./customer"; 11 | import { Movie } from "./movie"; 12 | 13 | @Entity("rentals") 14 | export class Rental extends BaseEntity { 15 | @PrimaryGeneratedColumn() 16 | rental_id: number; 17 | 18 | @ManyToOne(() => Customer, (customer) => customer.rentals) 19 | @JoinColumn({ name: "customer", referencedColumnName: "customer_id" }) 20 | customer: Customer; 21 | 22 | @ManyToOne(() => Movie, (movie) => movie.rentals) 23 | @JoinColumn({ name: "movie", referencedColumnName: "movie_id" }) 24 | movie: Movie; 25 | 26 | @CreateDateColumn() 27 | dateOut: Date; 28 | 29 | @Column({ type: "text", nullable: true }) 30 | dateReturned: string; 31 | 32 | @Column({ type: "decimal", nullable: true }) 33 | rentalFee: number; 34 | } 35 | -------------------------------------------------------------------------------- /src/validators/customer.ts: -------------------------------------------------------------------------------- 1 | import { isValidCustomerId } from "../utils/isValidId"; 2 | import zod from "zod"; 3 | 4 | export const createCustomerSchema = zod.object({ 5 | isGold: zod.boolean().optional(), 6 | name: zod 7 | .string({ required_error: "customer name is required" }) 8 | .min(5, "name too short - length should between 5 and 255") 9 | .max(255, "name too long - length should between 5 and 255"), 10 | phone: zod.string({ required_error: "customer phone number is required" }), 11 | }); 12 | 13 | export const updateCustomerSchema = createCustomerSchema.partial(); 14 | 15 | export const validCustomerIdParam = zod.object({ 16 | id: zod 17 | .string({ required_error: "customer id is required" }) 18 | .refine(isValidCustomerId, (val) => ({ 19 | message: `${val} invalid customerID`, 20 | })), 21 | }); 22 | 23 | export type createCustomerInput = zod.infer; 24 | export type updateCustomerInput = zod.infer; 25 | export type customerIdParamInput = zod.infer; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vidly", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon src/server.ts", 8 | "start": "ts-node src/server.ts", 9 | "build": "npm install", 10 | "test": "vitest", 11 | "test:cov": "vitest run --coverage" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "type": "module", 16 | "dependencies": { 17 | "bcrypt": "^5.1.0", 18 | "config": "^3.3.1", 19 | "express": "^4.18.2", 20 | "express-async-errors": "^3.1.1", 21 | "jsonwebtoken": "^8.5.1", 22 | "lodash": "^4.17.15", 23 | "moment": "^2.29.4", 24 | "pg": "^8.9.0", 25 | "typeorm": "^0.3.12", 26 | "winston": "^3.2.1", 27 | "zod": "^3.20.6" 28 | }, 29 | "devDependencies": { 30 | "@types/bcrypt": "^5.0.0", 31 | "@types/config": "^3.3.0", 32 | "@types/express": "^4.17.17", 33 | "@types/jsonwebtoken": "^9.0.1", 34 | "@types/lodash": "^4.14.191", 35 | "@types/node": "^18.13.0", 36 | "@types/pg": "^8.6.6", 37 | "@vitest/coverage-v8": "^2.1.3", 38 | "ts-node": "^10.9.1", 39 | "vitest": "^2.1.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/routes/customer.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | getCustomerController, 4 | createCustomerController, 5 | updateCustomerController, 6 | deleteCustomerController, 7 | } from "../controllers/customer"; 8 | import { 9 | createCustomerSchema, 10 | updateCustomerSchema, 11 | validCustomerIdParam, 12 | } from "../validators/customer"; 13 | import validateResource from "../middleware/validateResource"; 14 | import { isAuth } from "../middleware/isAuth"; 15 | import { isAdmin } from "../middleware/isAdmin"; 16 | 17 | const router = Router(); 18 | 19 | router.get("/:id", getCustomerController); 20 | 21 | router.post( 22 | "/", 23 | isAuth, 24 | validateResource({ body: createCustomerSchema }), 25 | createCustomerController 26 | ); 27 | 28 | router.put( 29 | "/:id", 30 | validateResource({ 31 | body: updateCustomerSchema, 32 | params: validCustomerIdParam, 33 | }), 34 | isAuth, 35 | updateCustomerController 36 | ); 37 | 38 | router.delete( 39 | "/:id", 40 | isAuth, 41 | isAdmin, 42 | validateResource({ 43 | params: validCustomerIdParam, 44 | }), 45 | deleteCustomerController 46 | ); 47 | 48 | export default router; 49 | -------------------------------------------------------------------------------- /src/validators/movie.ts: -------------------------------------------------------------------------------- 1 | import { isValidMovieId } from "../utils/isValidId"; 2 | import zod from "zod"; 3 | 4 | export const createMovieSchema = zod.object({ 5 | title: zod 6 | .string({ 7 | required_error: "movie title is required", 8 | }) 9 | .min(5, "movie title too short - should be at least 5 length") 10 | .max(50, "movie title too long - should be at least 50 length "), 11 | genreId: zod 12 | .number({ 13 | required_error: "movie genre Id is required", 14 | }) 15 | .array().max(4).min(1), 16 | numberInStock: zod.number({ 17 | required_error: "number of movies in stock is required", 18 | }), 19 | dailyRentalRate: zod.number({ 20 | required_error: "daily rental rate is required", 21 | }), 22 | }); 23 | 24 | export const updateMovieSchema = createMovieSchema.partial(); 25 | 26 | export const validMovieIdParam = zod.object({ 27 | id: zod 28 | .string({ 29 | required_error: "movie id parameter is required", 30 | }) 31 | .refine(isValidMovieId, (val) => ({ message: `${val} invalid movieID` })), 32 | }); 33 | 34 | export type createMovieInput = zod.infer; 35 | export type updateMovieInput = zod.infer; 36 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import "express-async-errors"; 2 | import express from "express"; 3 | import config from "config"; 4 | import createDataBaseConnection from "./database/connect"; 5 | import User from "./routes/user"; 6 | import Movie from "./routes/movie"; 7 | import Customer from "./routes/customer"; 8 | import Genre from "./routes/genre"; 9 | import Rental from "./routes/rental"; 10 | import { log } from "./utils/logger"; 11 | import { errorHandlerMiddleware } from "./middleware/errorHandler"; 12 | const app = express(); 13 | 14 | process.on("uncaughtException", (ex: any) => { 15 | log.error(ex.message); 16 | process.exit(1); 17 | }); 18 | 19 | process.on("unhandledRejection", (ex: any) => { 20 | log.error(ex.message); 21 | process.exit(1); 22 | }); 23 | 24 | app.use(express.json()); 25 | app.use("/api/user", User); 26 | app.use("/api/customer", Customer); 27 | app.use("/api/movie", Movie); 28 | app.use("/api/genre", Genre); 29 | app.use("/api/rental", Rental); 30 | app.use(errorHandlerMiddleware); 31 | 32 | const port = config.get("PORT") || 3000; 33 | createDataBaseConnection.initialize().then((conn) => { 34 | log.info(`connected postgres DataBase : ${conn.options.database} 🐘`); 35 | app.listen(port, () => log.info(`server start at port ${port} 🚀 🎯`)); 36 | }); 37 | -------------------------------------------------------------------------------- /src/database/entities/movie.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | UpdateDateColumn, 6 | Entity, 7 | PrimaryGeneratedColumn, 8 | ManyToMany, 9 | JoinTable, 10 | OneToMany, 11 | } from "typeorm"; 12 | import { Genre } from "./genre"; 13 | import { Rental } from "./rental"; 14 | 15 | @Entity("movies") 16 | export class Movie extends BaseEntity { 17 | @PrimaryGeneratedColumn() 18 | movie_id: number; 19 | 20 | @Column({ type: "varchar", length: 255, nullable: false }) 21 | title: string; 22 | 23 | @ManyToMany((type) => Genre, (genre) => genre.movies, { cascade: true }) 24 | @JoinTable({ 25 | name: "movies_genres", 26 | joinColumn: { 27 | name: "movie", 28 | referencedColumnName: "movie_id", 29 | }, 30 | inverseJoinColumn: { 31 | name: "genre", 32 | referencedColumnName: "genre_id", 33 | }, 34 | }) 35 | genres: Genre[]; 36 | 37 | @OneToMany(() => Rental, (rental) => rental.movie) 38 | rentals: Rental[]; 39 | 40 | @Column({ type: "int", default: 0.0 }) 41 | numberInStock: number; 42 | 43 | @Column({ type: "int", nullable: false }) 44 | dailyRentalRate: number; 45 | 46 | @CreateDateColumn() 47 | createdAt: Date; 48 | 49 | @UpdateDateColumn() 50 | updatedAt: Date; 51 | } 52 | -------------------------------------------------------------------------------- /__tests__/utils/token.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; 2 | import jwt from 'jsonwebtoken'; 3 | import config from 'config'; 4 | import {jwtPayload} from "../../src/types/jwtPayload"; 5 | import {createToken, verifyToken} from "../../src/utils/token" 6 | 7 | vi.mock('config'); 8 | vi.mock('jsonwebtoken'); 9 | 10 | describe('Token Utils', () => { 11 | const mockJWTSecret = 'mock_secret'; 12 | const mockPayload: jwtPayload = { _id: 1231, isAdmin: true }; 13 | 14 | beforeEach(() => { 15 | vi.resetAllMocks(); 16 | (config.get as Mock).mockReturnValue(mockJWTSecret); 17 | }); 18 | 19 | describe('createToken', () => { 20 | it('should create a JWT token with the given payload and secret', () => { 21 | const mockToken = 'mockToken'; 22 | (jwt.sign as Mock).mockReturnValue(mockToken); 23 | 24 | const token = createToken(mockPayload); 25 | 26 | expect(jwt.sign).toHaveBeenCalledWith(mockPayload, mockJWTSecret, { expiresIn: '15m' }); 27 | expect(token).toBe(mockToken); 28 | }); 29 | }); 30 | 31 | describe('verifyToken', () => { 32 | it('should verify a valid JWT token and return the payload', () => { 33 | const mockDecodedPayload = { _id: 1231, isAdmin: true }; 34 | (jwt.verify as Mock).mockReturnValue(mockDecodedPayload); 35 | 36 | const result = verifyToken('validToken'); 37 | 38 | expect(jwt.verify).toHaveBeenCalledWith('validToken', mockJWTSecret); 39 | expect(result).toBe(mockDecodedPayload); 40 | }); 41 | 42 | it('should throw an error for an invalid JWT token', () => { 43 | (jwt.verify as Mock).mockImplementation(() => { 44 | throw new Error('invalid Token Provided'); 45 | }); 46 | 47 | expect(() => verifyToken('invalidToken')).toThrow('invalid Token Provided'); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/controllers/genre.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { create_updateGenreInput } from "../validators/genre"; 3 | import { Genre } from "../database/entities"; 4 | 5 | export const getGenreController = async ( 6 | req: Request<{ id: string }, {}>, 7 | res: Response 8 | ) => { 9 | /** 10 | * DONE: check if genre with that id exist or not 11 | * TODO: make search with req.query wich mean more dynamic search 12 | */ 13 | const id: number = parseInt(req.params.id as string); 14 | const genre = await Genre.findOne({ where: { genre_id: id } }); 15 | return res.send(genre); 16 | }; 17 | 18 | export const createGenreController = async ( 19 | req: Request<{}, {}, create_updateGenreInput>, 20 | res: Response 21 | ) => { 22 | /** 23 | * DONE: validate request body to match create genre criteria 24 | * DONE: check if name of genre is unique or not 25 | * DONE: create nre genre 26 | * 27 | */ 28 | let genre = await Genre.findOne({ where: { name: req.body.name } }); 29 | if (genre) return res.status(400).send("genre name already exist"); 30 | 31 | genre = await Genre.create({ name: req.body.name }).save(); 32 | return res.send(genre); 33 | }; 34 | 35 | export const updateGenreController = async ( 36 | req: Request<{ id: string }, {}, create_updateGenreInput>, 37 | res: Response 38 | ) => { 39 | /** 40 | * DONE: validate parameter's id if genre exist or not 41 | * DONE: validate request body to match update genre or not 42 | * DONE: update genre 43 | */ 44 | const id: number = parseInt(req.params.id as string); 45 | let genre = await Genre.createQueryBuilder("genres") 46 | .update(Genre) 47 | .set(req.body) 48 | .where("genre_id = :id ", { id }) 49 | .returning("*") 50 | .execute(); 51 | return res.send(genre.raw[0]); 52 | }; 53 | 54 | export const deleteGenreController = async ( 55 | req: Request<{ id: string }>, 56 | res: Response<{ deleted: boolean; message: string }> 57 | ) => { 58 | /** 59 | * DONE: validate parameter's id if genre exists or not 60 | * DONE: delete genre 61 | */ 62 | const id: number = parseInt(req.params.id as string); 63 | const { affected } = await Genre.delete({ genre_id: id }); 64 | 65 | return res.send({ 66 | deleted: Boolean(affected), 67 | message: affected 68 | ? "genre deleted succesfully" 69 | : "not deleted something went wrong", 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /src/controllers/user.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import bcrypt from "bcrypt"; 3 | import _ from "lodash"; 4 | import { UserType } from "../types/User"; 5 | import { loginInput, registerInput } from "../validators/user"; 6 | import { User } from "../database/entities/index"; 7 | import { createToken } from "../utils/token"; 8 | 9 | export const registerController = async ( 10 | req: Request<{}, {}, registerInput>, 11 | res: Response 12 | ) => { 13 | /** 14 | * DONE: validate request body to match regsiter criteria 15 | * DONE: check if email is unique in database or not 16 | * DONE: hash password 17 | * DONE: send jwt and created user 18 | */ 19 | let { name, email, password } = req.body; 20 | let userExist = await User.findOne({ where: { email } }); 21 | if (userExist) return res.status(400).send("email already exist"); 22 | 23 | const salt = await bcrypt.genSalt(10); 24 | password = await bcrypt.hash(password, salt); 25 | [name, email, password]; 26 | 27 | let user = await User.createQueryBuilder() 28 | .insert() 29 | .into(User) 30 | .values({ name, email, password }) 31 | .returning("*") 32 | .execute(); 33 | 34 | const token = createToken({ _id: user.raw[0].user_id }); 35 | 36 | return res 37 | .header("x-auth-token", token) 38 | .send(_.pick(user.raw[0], ["name", "email"]) as UserType); 39 | }; 40 | 41 | export const loginController = async ( 42 | req: Request<{}, {}, loginInput>, 43 | res: Response 44 | ) => { 45 | /** 46 | * DONE: validate reqeust body to match login criteria 47 | * DONE: check if email exist 48 | * DONE: compare passwords 49 | * DONE: every thing is ok ? return token and user data : return 400 error 50 | */ 51 | try { 52 | const { email, password } = req.body; 53 | let user = await User.findOne({ where: { email } }); 54 | if (!user) return res.status(400).send("invalid user name or password"); 55 | 56 | const validPassword = await bcrypt.compare(password, user.password); 57 | if (!validPassword) 58 | return res.status(400).send("invalid user name or password"); 59 | 60 | const token = createToken({ 61 | _id: user.user_id, 62 | isAdmin: user.isAdmin, 63 | }); 64 | 65 | return res 66 | .header("x-auth-token", token) 67 | .send(_.pick(user, ["name", "email"]) as UserType); 68 | } catch (error) { 69 | throw new Error(error); 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /src/controllers/movie.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { In } from "typeorm"; 3 | import { Genre, Movie } from "../database/entities"; 4 | import { createMovieInput, updateMovieInput } from "../validators/movie"; 5 | 6 | export const getMovieController = async ( 7 | req: Request<{ id: string }, {}, {}>, 8 | res: Response | string> 9 | ) => { 10 | /** 11 | * DONE: check if provided id found or not 12 | */ 13 | const movie = await Movie.findOne({ where: { movie_id: parseInt(req.params.id) } }); 14 | if (!movie) return res.status(404).send("no file with that id "); 15 | 16 | return res.send(movie); 17 | }; 18 | 19 | export const createMovieController = async ( 20 | req: Request<{}, {}, createMovieInput>, 21 | res: Response 22 | ) => { 23 | /** 24 | * DONE: validate request body to match create movie criteria 25 | * DONE: create movie 26 | * */ 27 | let genres = await Genre.find({ 28 | where: { genre_id: In(req.body.genreId) }, 29 | }); 30 | 31 | if (genres.length < req.body.genreId.length) 32 | return res.status(404).send("invalid genreId"); 33 | 34 | const movieEntry = { 35 | ...req.body, 36 | genres, 37 | }; 38 | 39 | const movie2 = await Movie.create(movieEntry).save(); 40 | 41 | return res.send(movie2); 42 | }; 43 | 44 | export const updateMovieController = async ( 45 | req: Request<{ id: string }, {}, updateMovieInput>, 46 | res: Response 47 | ) => { 48 | /** 49 | * DONE: validate movie id in parameters 50 | * DONE: validate request body to check if it match criteria 51 | * DONE: update movie 52 | */ 53 | const id: number = parseInt(req.params.id as string); 54 | 55 | let movie = await Movie.createQueryBuilder("movies") 56 | .update(Movie) 57 | .set(req.body) 58 | .where("movie_id = :id ", { id }) 59 | .returning("*") 60 | .execute(); 61 | 62 | return res.send(movie); 63 | }; 64 | 65 | export const deleteMovieController = async ( 66 | req: Request<{ id: string }>, 67 | res: Response<{ deleted: boolean; message: string }> 68 | ) => { 69 | /** 70 | * DONE: validate if movie id exists 71 | * DONE: delete movie 72 | */ 73 | 74 | const id: number = parseInt(req.params.id as string); 75 | await Movie.createQueryBuilder("movies") 76 | .delete() 77 | .from(Movie) 78 | .where("movie_id=:id", { id }) 79 | .execute(); 80 | return res.send({ deleted: true, message: "movie deleted succesfully" }); 81 | }; 82 | -------------------------------------------------------------------------------- /src/controllers/customer.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { 3 | createCustomerInput, 4 | updateCustomerInput, 5 | } from "../validators/customer"; 6 | import { Customer } from "../database/entities/customer"; 7 | 8 | export const getCustomerController = async ( 9 | req: Request<{ id: string }>, 10 | res: Response 11 | ) => { 12 | const id: number = parseInt(req.params.id as string); 13 | let customer = await Customer.findOne({ 14 | where: { customer_id: id }, 15 | }); 16 | 17 | if (!customer) return res.status(404).send("no customer found with that id"); 18 | 19 | return res.send(customer); 20 | }; 21 | 22 | export const createCustomerController = async ( 23 | req: Request<{ id: string }, {}, createCustomerInput>, 24 | res: Response 25 | ) => { 26 | /** 27 | * DONE: validate request body to match create customer cirteria 28 | * DONE: apply middleware to check if user is authenticated or not 29 | * DONE: check if customer must be unique 30 | * DONE: create a new customer 31 | */ 32 | let { name, isGold, phone } = req.body; 33 | isGold = isGold || false; 34 | 35 | let customerExists = await Customer.findOne({ where: { name } }); 36 | if (customerExists) return res.send("customer name already exist"); 37 | 38 | let customer = await Customer.createQueryBuilder() 39 | .insert() 40 | .into(Customer) 41 | .values({ name, isGold, phone }) 42 | .returning("*") 43 | .execute(); 44 | 45 | return res.send(customer.raw[0]); 46 | }; 47 | 48 | export const updateCustomerController = async ( 49 | req: Request<{ id: string }, {}, updateCustomerInput>, 50 | res: Response 51 | ) => { 52 | const id: number = parseInt(req.params.id as string); 53 | 54 | let customer = await Customer.createQueryBuilder("customers") 55 | .update(Customer) 56 | .set(req.body) 57 | .where("customer_id = :id ", { id }) 58 | .returning("*") 59 | .execute(); 60 | 61 | return res.send(customer.raw[0]); 62 | }; 63 | 64 | export const deleteCustomerController = async ( 65 | req: Request<{ id: string }>, 66 | res: Response<{ deleted: boolean; message: string }> 67 | ) => { 68 | const id: number = parseInt(req.params.id as string); 69 | await Customer.createQueryBuilder() 70 | .delete() 71 | .from(Customer) 72 | .where("customer_id=:id", { id }) 73 | .execute(); 74 | return res.send({ deleted: true, message: "customer deleted succesfully" }); 75 | }; 76 | -------------------------------------------------------------------------------- /ERD.drawio: -------------------------------------------------------------------------------- 1 | 7V1bc6M2FP41ftwd7pDHxrlsp8l0x9l2t08drVFsphi5ICd2f30lI2FAYOOLQA7MZCZISDI639GHdI50GJnjxfoxBsv5M/JhODI0fz0y70aGoWumR/7RnE2a42humjGLA58V2mW8BP9BXpPlrgIfJoWCGKEQB8ti5hRFEZziQh6IY/ReLPaKwuKvLsEMChkvUxCKud8DH8/TXEvXtN2NLzCYzdlPWxq/swC8NMtI5sBH77ks835kjmOEcHq1WI9hSKXHBZPWe6i5mz1ZDCPcpMLDzdsn+0/3i6Gh7+jHv3evS9f7xFp5A+GK9Xi8SjBawDhhT403XBakA0t6icFPmnWbYBBjBpmlkQwCAgZBBGOSoW/TYQiWSbAtnubMg9B/Ahu0wrwhnrp9DdbQn6SI0bIEvCfSGE3Sxl9J4y/sYehtEAaziFxPSffpL97GMCHP8gQSzEpsa6SPZ1gkzboKYwzXtTLUM2SITkMiCBxvSBFWwfAYmEydMz193ymH7rK8eU4vskzAFHKWtb2DjFww1I5A0BAQrAWO9BMHIJyQgQKi2RbDIkRUzn6Mlt9APIOYZSxRQCV8/0ZklIERhOEYhYgiHaGItoTRkt0M4Suv+xNhokwcUCaOrNGtKOxb8keEM9Y+2yObPO6YpPVdmvzR4jEeoyjBMdEv2gYkML9DCnUzWOuVX8R6w4dyM2gtWciaArJffzsO2y0ngh22R8OmlWArD0JExP4ablltHvg+jM7Cw6jGIweA2ab8LUH+U8aNf5N63QLB2S8te5sswTSIZk9pTaeElF2DVJ4gNRnIrYuo5UeS1SaS9lVwZBnjK+BIryFHmrKQdc5E9uIDE/oBb+/yBGmfRJDShO8Kwo/AAqrOjHKZsAaiI5lQGmTewIRymFBvuhKQBu1Nr6jQU4sKOfo56QfJI7U29JoNa1BShQ110fwx0OFl6LDp6lketucaRq6LD/UaPDojRNF6sZxTqfWaD+tQUoYQxSn9M3oL4GAMrkDLdorGYNOpgO+mAr7Manx5/K5jfq+mNdjd+0areqFVgSvNiKWLE/wPbg/WT5vkS0PAECf5C8qOgzm4IXB7XnNOm0DyBxhY8VxWrDIAt8qKpriEe/jYrGhq1Yh0xYqmuNCawSgeWLEpcKqwojFYQ2TRpG405ElpKztDHKUdj06p5pC6pVtX5hBDNIfgAIc9N4ccWGA3ZUR5qInmkIERL8SIdueMeK6t5MoYsQaPzhhRNGdEq8VPGP8avWA0/afnzFiDljLMKG6OG5jxQszYdE0tD9xz98tdGTNaijGjuKnNB0G4mZBuEEkD3PdZYw1eqnCjKc71B24UsT3gXCudtLCaOtfkwXruK++6WDHV4v2saH62jRtL113HcjXb86w24RBZ8pGaHQdXdcVo8uziaKo8t1T1ktOluarN61hbK+mUSXX/KFd1uy4BcWn9wV3V5mmra3kIiKvrwSlzDHB7JpNam0DyhhWnya4nk6fQZNU6u9WFnnWuw+26ZpTWaY5reeI3BPEP55fqQDqSD6WBZjfgQ+jPIH+DkN4GeDOBIcABiu53d/Iy00mPb+d4ETK9h5H/Cw0eQZL3EwLiN/QMog29sQ7wDz5WyPVf9PqzzVJ369ytuw1PRKTfP7KWSSJXiyZ31bYpXi/tFu1LLYIsK0GreAoPvuQMDXPery14eIhWwp1lxltBvxUfuUoJ2G98pe+J3DpFL65T7PLyI+0qq7VTJaGhm1JDVrmhVBRCQ1udzDp+hpqK1J6a74blaYOwGlZTE7i8ndS2+HJQcd6l5PLUPj6wRqt7Bu3qyBrjD75xMFXpveRutwrDEGDjbOzWRdw6i7DhWAM7XoYdO99R7Yje3z6wo3PYC9wqOzqif2M4bHIMcKpQoz1EH5LFld1vq7Z7FoDocASiKpKUJ37RiegDDH9fYdU5UvJ0cX8YoqacKA+36zinfJWc2PnGartfkYjsw6eUW+VEvlGqxIkTiFdxBJWfPEomxv0nkzsnRmc4gyeNGDvfV+2ca1O+LmJ0Dp/Ba5cYRbNvvPXJPMCeO33rkFKFFV2RFf94uZ+81KLWY19aeaunXeWybzdEvXsu7/XYWuwqHqTe7V2UerfGFdPVVk9XdKOtksGF1hg3VYLUu4OdWBZLdh6m3u2XmdhVLVD9EKm+OUiqxCJ1BxuxLD7sPli92y8bsatYuHr+QsyJHy5AEPacERUPV+8NxmFpjNh5vHqvX8ZhT7EAbV5FvHqQJO8oVn4ZLfkTHoqHrPcahGW47IGgRXoWaGtbFnL5CSF9dMIJoSzR+gkhvn/34AmhBjG5pZ4Qsjgp8w8Kn3pCyC41JBw1knxCyGtg7snrLXkrztEMRSCUprPadeksP6JzUGcPG2ylqmzGh2WPzLEqm5Eo/+BE2WNTo7IEcLDJFWMTstoHNsouJNaB2ucqRxdxC+XJRfoEDccPSe6+ip0W331c3Lz/Hw== -------------------------------------------------------------------------------- /__tests__/utils/isValidId.test.ts: -------------------------------------------------------------------------------- 1 | import { Mock, describe, expect, it, vi } from "vitest"; 2 | import { Genre } from "../../src/database/entities/genre"; 3 | import {Movie} from "../../src/database/entities/movie"; 4 | import {Customer} from "../../src/database/entities/customer"; 5 | import { isValidCustomerId, isValidGenreId, isValidMovieId } from "../../src/utils/isValidId"; 6 | 7 | vi.mock("../../src/database/entities/genre", () => ({ 8 | Genre: { 9 | findOne: vi.fn(), 10 | } 11 | })); 12 | 13 | vi.mock("../../src/database/entities/movie", () => ({ 14 | Movie: { 15 | findOne: vi.fn(), 16 | } 17 | })); 18 | 19 | vi.mock("../../src/database/entities/customer", () => ({ 20 | Customer: { 21 | findOne: vi.fn(), 22 | } 23 | })); 24 | 25 | const createGenre = (overrides = {}): Partial => ({ 26 | genre_id: 1, 27 | name: "Action", 28 | createdAt: new Date(), 29 | updatedAt: new Date(), 30 | ...overrides, 31 | }); 32 | 33 | const createMovie = (overrides = {}): Partial => ({ 34 | movie_id: 1, 35 | title: "The Dark Knight", 36 | genres: [createGenre()] as any, 37 | numberInStock: 10, 38 | dailyRentalRate: 2, 39 | createdAt: new Date(), 40 | updatedAt: new Date(), 41 | ...overrides, 42 | }); 43 | 44 | const createCustomer = (overrides = {}): Partial => ({ 45 | customer_id: 1, 46 | name: "John Doe", 47 | isGold: false, 48 | phone: "1234567890", 49 | createdAt: new Date(), 50 | updatedAt: new Date(), 51 | ...overrides, 52 | }); 53 | 54 | describe("isValidGenreId",()=>{ 55 | 56 | it("should return true if genre exists", async()=>{ 57 | (Genre.findOne as Mock).mockResolvedValueOnce(createGenre()); 58 | 59 | const result = await isValidGenreId("1"); 60 | 61 | expect(result).toBe(true); 62 | }) 63 | 64 | it("should return false if genre does not exist", async()=>{ 65 | (Genre.findOne as Mock).mockResolvedValueOnce(null); 66 | 67 | const result = await isValidGenreId("15"); 68 | 69 | expect(result).toBe(false); 70 | }) 71 | }) 72 | 73 | describe("isValidMovieId",()=>{ 74 | it("should return true if movie exists", async()=>{ 75 | (Movie.findOne as Mock).mockResolvedValueOnce(createMovie()); 76 | 77 | const result = await isValidMovieId("2"); 78 | 79 | expect(result).toBe(true); 80 | }) 81 | 82 | it("should return false if movie does not exist", async()=>{ 83 | (Movie.findOne as Mock).mockResolvedValueOnce(null); 84 | 85 | const result = await isValidMovieId("16"); 86 | 87 | expect(result).toBe(false); 88 | }) 89 | }); 90 | 91 | describe("isValidCustomerId",()=>{ 92 | it("should return true if customer exists", async()=>{ 93 | (Customer.findOne as Mock).mockResolvedValueOnce(createCustomer()); 94 | 95 | const result = await isValidCustomerId("1"); 96 | 97 | expect(result).toBe(true); 98 | }) 99 | 100 | it("should return false if customer does not exist", async()=>{ 101 | (Customer.findOne as Mock).mockResolvedValueOnce(null); 102 | 103 | const result = await isValidCustomerId("15"); 104 | 105 | expect(result).toBe(false); 106 | }) 107 | }); -------------------------------------------------------------------------------- /src/controllers/rental.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import { Request, Response } from "express"; 3 | import { rentalPrice } from "../utils/rentalPrice"; 4 | import { createRentalInput } from "../validators/rental"; 5 | import { Customer, Movie, Rental } from "../database/entities"; 6 | import dataSource from "../database/connect"; 7 | 8 | export const getRentalsContrller = async ( 9 | req: Request< 10 | {}, 11 | {}, 12 | { dateOut?: Date; dateReturned?: Date; customer?: number; movie?: number } 13 | >, 14 | res: Response 15 | ) => { 16 | /** 17 | * DONE: return all rentals in the shop 18 | */ 19 | let querySrting = " "; 20 | let queryKeys = Object.keys(req.query); 21 | 22 | for (let index = 0; index < queryKeys.length; index++) { 23 | if (req.query[queryKeys[index]] === "customer") { 24 | querySrting += `customers.customer_id = ${req.query[queryKeys[index]]} `; 25 | } else { 26 | querySrting += ` 27 | rentals.${queryKeys[index]} = ${req.query[queryKeys[index]]} `; 28 | } 29 | if (index < queryKeys.length - 1) { 30 | querySrting += `AND`; 31 | } 32 | } 33 | 34 | const rentals = await dataSource 35 | .getRepository(Rental) 36 | .createQueryBuilder("rentals") 37 | .leftJoinAndSelect("rentals.customer", "customers") 38 | .where(querySrting) 39 | .getMany(); 40 | 41 | res.send(rentals); 42 | }; 43 | 44 | export const addRentalController = async ( 45 | req: Request<{}, createRentalInput>, 46 | res: Response 47 | ) => { 48 | /** 49 | * DONE: validate customerId and movieId body exists 50 | * DONE: decrease movie number in stock 51 | * DONE: add new rental 52 | */ 53 | const movie = await Movie.findOne({ where: { movie_id: req.body.movieId } }); 54 | if (!movie) return res.send("no movie found with that id "); 55 | 56 | const customer = await Customer.findOne({ 57 | where: { customer_id: req.body.customerId }, 58 | }); 59 | if (!customer) return res.send("no customer found with that id"); 60 | 61 | let rental; 62 | await dataSource.transaction(async () => { 63 | movie.numberInStock -= 1; 64 | await movie.save(); 65 | 66 | rental = await Rental.create({ 67 | customer, 68 | movie, 69 | }).save(); 70 | }); 71 | return res.send(rental); 72 | }; 73 | 74 | export const backRentalController = async ( 75 | req: Request<{ rentalId: string }, {}, {}, { dateOut?: Date }>, 76 | res: Response 77 | ) => { 78 | /** 79 | * DONE: validate if rental exist or not 80 | * DONE: calculate rental price using rentalPrice util function 81 | * DONE: update rental table to contain movie's date returned and rental fee 82 | * DONE: update movie number in stock 83 | * DONE: add transaction to commit both update movie and rental 84 | * DONE: return the rental 85 | */ 86 | const rental_id: number = parseInt(req.params.rentalId as string); 87 | const rental = await dataSource 88 | .getRepository(Rental) 89 | .createQueryBuilder("rentals") 90 | .leftJoinAndSelect("rentals.movie", "movies") 91 | .where("rentals.rental_id = :id", { id: rental_id }) 92 | .getOne(); 93 | if (!rental) return res.status(404).send("no rental found"); 94 | 95 | await dataSource.transaction(async () => { 96 | rental.rentalFee = rentalPrice( 97 | rental.dateOut, 98 | rental.movie.dailyRentalRate 99 | ); 100 | rental.dateReturned = moment().format("YYYY-MM-DD"); 101 | await rental.save(); 102 | 103 | await Movie.update( 104 | { movie_id: rental.movie.movie_id }, 105 | { numberInStock: rental.movie.numberInStock + 1 } 106 | ); 107 | }); 108 | 109 | return res.send(rental); 110 | }; 111 | -------------------------------------------------------------------------------- /__tests__/controllers/genre.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, Mock, vi, beforeEach } from 'vitest'; 2 | import { Genre } from "../../src/database/entities/genre"; 3 | import { createGenreController, getGenreController, updateGenreController } from "../../src/controllers/genre"; 4 | import { Request, Response } from 'express'; 5 | import { create_updateGenreInput } from 'src/validators/genre'; 6 | 7 | vi.mock("../../src/database/entities/genre", () => ({ 8 | Genre: { 9 | findOne: vi.fn(), 10 | create: vi.fn(() => ({ 11 | save: vi.fn() 12 | })), 13 | createQueryBuilder: vi.fn(() => ({ 14 | update: vi.fn().mockReturnThis(), 15 | delete: vi.fn().mockReturnThis(), 16 | from: vi.fn().mockReturnThis(), 17 | set: vi.fn().mockReturnThis(), 18 | where: vi.fn().mockReturnThis(), 19 | returning: vi.fn().mockReturnThis(), 20 | execute: vi.fn() 21 | })), 22 | } 23 | })) 24 | 25 | const createMockResponse = () => { 26 | const send = vi.fn(); 27 | const status = vi.fn(() => ({ send })); 28 | 29 | return { 30 | res: { 31 | status, 32 | send, 33 | } as unknown as Partial, 34 | send, 35 | status, 36 | } 37 | } 38 | 39 | const createGenreObj = (overrides = {}): Partial => ({ 40 | genre_id: 1, 41 | name: "Test Genre", 42 | createdAt: new Date(), 43 | updatedAt: new Date(), 44 | ...overrides, 45 | }); 46 | 47 | describe("getGenreController", () => { 48 | let req: Partial; 49 | let res: Partial; 50 | let send: Mock; 51 | let status: Mock; 52 | 53 | let genreObj: Partial; 54 | 55 | beforeEach(() => { 56 | req = { 57 | params: { 58 | id: "1" 59 | } 60 | }; 61 | 62 | ({ res, send, status } = createMockResponse()); 63 | 64 | genreObj = createGenreObj(); 65 | }) 66 | 67 | it("should return genre", async () => { 68 | (Genre.findOne as Mock).mockResolvedValueOnce(genreObj); 69 | 70 | await getGenreController(req as Request<{ id: string }>, res as Response); 71 | 72 | expect(send).toHaveBeenCalledWith(genreObj); 73 | }) 74 | }) 75 | 76 | describe("createGenreController", () => { 77 | let req: Partial; 78 | let res: Partial; 79 | let send: Mock; 80 | let status: Mock; 81 | 82 | let genreObj: Partial; 83 | 84 | beforeEach(() => { 85 | req = { 86 | body: { 87 | name: "Test Genre" 88 | } 89 | } as unknown as Partial; 90 | 91 | ({ res, send, status } = createMockResponse()); 92 | genreObj = createGenreObj(); 93 | }) 94 | 95 | it("should should return 404 if Genere Exist", async () => { 96 | (Genre.findOne as Mock).mockResolvedValueOnce(genreObj); 97 | 98 | await createGenreController(req as Request, res as Response); 99 | 100 | expect(status).toHaveBeenCalledWith(400); 101 | expect(send).toHaveBeenCalledWith("genre name already exist"); 102 | }); 103 | 104 | it("should return created genre", async () => { 105 | (Genre.findOne as Mock).mockResolvedValueOnce(null); 106 | (Genre.create as Mock).mockReturnValueOnce({ 107 | save: vi.fn().mockResolvedValueOnce(genreObj) 108 | }); 109 | 110 | await createGenreController(req as Request, res as Response); 111 | 112 | expect(send).toHaveBeenCalledWith(genreObj); 113 | }) 114 | }) 115 | 116 | describe("updateGenreController", () => { 117 | let req: Partial; 118 | let res: Partial; 119 | let send: Mock; 120 | let status: Mock; 121 | 122 | let genreObj: Partial; 123 | 124 | beforeEach(() => { 125 | req = { 126 | params: { 127 | id: "1" 128 | }, 129 | body: { 130 | name: "Updated Genre" 131 | } 132 | } as unknown as Partial; 133 | 134 | ({ res, send, status } = createMockResponse()); 135 | 136 | genreObj = { 137 | ...createGenreObj(), 138 | name: "Updated Genre" 139 | } 140 | }); 141 | 142 | it("should return updated genre", async()=>{ 143 | (Genre.createQueryBuilder as Mock).mockReturnValueOnce({ 144 | update: vi.fn().mockReturnThis(), 145 | set: vi.fn().mockReturnThis(), 146 | where: vi.fn().mockReturnThis(), 147 | returning: vi.fn().mockReturnThis(), 148 | execute: vi.fn().mockResolvedValueOnce({ raw: [genreObj] }) 149 | }); 150 | 151 | await updateGenreController(req as Request<{id: string},{},create_updateGenreInput>, res as Response); 152 | 153 | expect(send).toHaveBeenCalledWith(genreObj); 154 | }) 155 | }); -------------------------------------------------------------------------------- /__tests__/controllers/user.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; 2 | import { loginController, registerController } from '../../src/controllers/user'; 3 | import { createToken } from "../../src/utils/token"; 4 | import bcrypt from "bcrypt"; 5 | import { User } from "../../src/database/entities/user"; 6 | import { Request, Response } from 'express'; 7 | 8 | vi.mock("bcrypt"); 9 | vi.mock("../../src/database/entities/user", () => ({ 10 | User: { 11 | findOne: vi.fn(), 12 | createQueryBuilder: vi.fn(() => ({ 13 | insert: vi.fn().mockReturnThis(), 14 | into: vi.fn().mockReturnThis(), 15 | values: vi.fn().mockReturnThis(), 16 | returning: vi.fn().mockReturnThis(), 17 | execute: vi.fn(), 18 | })), 19 | }, 20 | })); 21 | 22 | vi.mock("../../src/utils/token", () => ({ 23 | createToken: vi.fn(), 24 | })); 25 | 26 | // Helper to create mock request and response 27 | const createMockResponse = () => { 28 | const send = vi.fn(); 29 | const status = vi.fn(() => ({ send })); 30 | const header = vi.fn(() => ({ send })); 31 | 32 | return { 33 | res: { 34 | status, 35 | header, 36 | } as unknown as Partial, 37 | send, 38 | status, 39 | header, 40 | }; 41 | }; 42 | 43 | const createUserObj = (overrides = {}) => ({ 44 | user_id: 1, 45 | name: "Test User", 46 | email: "test@example.com", 47 | password: 'hashedPassword123', 48 | isAdmin: false, 49 | createdAt: new Date(), 50 | updatedAt: new Date(), 51 | ...overrides, 52 | }); 53 | 54 | // Test suite for registerController 55 | describe("registerController", () => { 56 | let req: Partial; 57 | let res: Partial; 58 | let send: Mock; 59 | let status: Mock; 60 | let header: Mock; 61 | 62 | let userObj: Partial; 63 | 64 | beforeEach(() => { 65 | req = { 66 | body: { 67 | name: "Test User", 68 | email: "test@example.com", 69 | password: 'password123', 70 | }, 71 | }; 72 | 73 | ({ res, send, status, header } = createMockResponse()); 74 | userObj = createUserObj(); 75 | }); 76 | 77 | it('should return 400 if the email already exists', async () => { 78 | (User.findOne as Mock).mockResolvedValueOnce(userObj as User); 79 | 80 | await registerController(req as Request, res as Response); 81 | 82 | expect(status).toHaveBeenCalledWith(400); 83 | expect(send).toHaveBeenCalledWith("email already exist"); 84 | }); 85 | 86 | it('should create a new user and return the token and user details', async () => { 87 | (User.findOne as Mock).mockResolvedValueOnce(null); 88 | (bcrypt.genSalt as Mock).mockResolvedValueOnce('salt'); 89 | (bcrypt.hash as Mock).mockResolvedValueOnce('hashedPassword'); 90 | (createToken as Mock).mockReturnValueOnce('mockedToken'); 91 | 92 | const executeMock = vi.fn().mockResolvedValueOnce({ raw: [userObj as User] }); 93 | (User.createQueryBuilder as Mock).mockReturnValueOnce({ 94 | insert: vi.fn().mockReturnThis(), 95 | into: vi.fn().mockReturnThis(), 96 | values: vi.fn().mockReturnThis(), 97 | returning: vi.fn().mockReturnThis(), 98 | execute: executeMock, 99 | }); 100 | 101 | await registerController(req as Request, res as Response); 102 | 103 | expect(bcrypt.genSalt).toHaveBeenCalledWith(10); 104 | expect(bcrypt.hash).toHaveBeenCalledWith('password123', 'salt'); 105 | expect(createToken).toHaveBeenCalledWith({ _id: userObj.user_id }); 106 | expect(header).toHaveBeenCalledWith("x-auth-token", "mockedToken"); 107 | expect(send).toHaveBeenCalledWith({ name: userObj.name, email: userObj.email }); 108 | }); 109 | }); 110 | 111 | // Test suite for loginController 112 | describe("loginController", () => { 113 | let req: Partial; 114 | let res: Partial; 115 | let send: Mock; 116 | let status: Mock; 117 | let header: Mock; 118 | 119 | let userObj: Partial; 120 | 121 | beforeEach(() => { 122 | req = { 123 | body: { 124 | email: "test@gmail.com", 125 | password: "Password123", 126 | }, 127 | }; 128 | 129 | ({ res, send, status, header } = createMockResponse()); 130 | userObj = createUserObj(); 131 | }); 132 | 133 | it('should return 400 if the user does not exist', async () => { 134 | (User.findOne as Mock).mockResolvedValueOnce(null); 135 | 136 | await loginController(req as Request, res as Response); 137 | 138 | expect(status).toHaveBeenCalledWith(400); 139 | expect(send).toHaveBeenCalledWith("invalid user name or password"); 140 | }); 141 | 142 | it('should return 400 if the password is invalid', async () => { 143 | (User.findOne as Mock).mockResolvedValueOnce(userObj as User); 144 | (bcrypt.compare as Mock).mockResolvedValueOnce(false); 145 | 146 | await loginController(req as Request, res as Response); 147 | 148 | expect(bcrypt.compare).toHaveBeenCalledWith("Password123", userObj.password); 149 | expect(status).toHaveBeenCalledWith(400); 150 | expect(send).toHaveBeenCalledWith("invalid user name or password"); 151 | }); 152 | 153 | it('should return the token and user details if the user exists and password is valid', async () => { 154 | (User.findOne as Mock).mockResolvedValueOnce(userObj as User); 155 | (bcrypt.compare as Mock).mockResolvedValueOnce(true); 156 | (createToken as Mock).mockReturnValueOnce('mockedToken'); 157 | 158 | await loginController(req as Request, res as Response); 159 | 160 | expect(createToken).toHaveBeenCalledWith({ _id: userObj.user_id, isAdmin: userObj.isAdmin }); 161 | expect(header).toHaveBeenCalledWith("x-auth-token", "mockedToken"); 162 | expect(send).toHaveBeenCalledWith({ name: userObj.name, email: userObj.email }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /__tests__/controllers/customer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; 2 | import { Request, Response } from 'express'; 3 | import { Customer } from "../../src/database/entities/customer"; 4 | import { createCustomerController, deleteCustomerController, getCustomerController, updateCustomerController } from '../../src/controllers/customer'; 5 | import { createCustomerInput } from 'src/validators/customer'; 6 | 7 | vi.mock("../../src/database/entities/customer", () => ({ 8 | Customer: { 9 | findOne: vi.fn(), 10 | create: vi.fn(() => ({ 11 | save: vi.fn() 12 | })), 13 | createQueryBuilder: vi.fn(() => ({ 14 | update: vi.fn().mockReturnThis(), 15 | delete: vi.fn().mockReturnThis(), 16 | from: vi.fn().mockReturnThis(), 17 | set: vi.fn().mockReturnThis(), 18 | where: vi.fn().mockReturnThis(), 19 | insert: vi.fn().mockReturnThis(), 20 | into: vi.fn().mockReturnThis(), 21 | values: vi.fn().mockReturnThis(), 22 | returning: vi.fn().mockReturnThis(), 23 | execute: vi.fn() 24 | })), 25 | } 26 | })); 27 | 28 | const createMockResponse = () => { 29 | const send = vi.fn(); 30 | const status = vi.fn(() => ({ send })); 31 | const header = vi.fn(() => ({ send })); 32 | 33 | return { 34 | res: { 35 | status, 36 | header, 37 | send, 38 | } as unknown as Partial, 39 | send, 40 | status, 41 | header, 42 | } 43 | } 44 | 45 | const createCustomerObj = (overrides = {}): Partial => ({ 46 | customer_id: 1, 47 | name: "Test Customer", 48 | isGold: false, 49 | phone: "1234567890", 50 | createdAt: new Date(), 51 | updatedAt: new Date(), 52 | ...overrides, 53 | }); 54 | 55 | describe("getCustomerController", () => { 56 | let req: Partial; 57 | let res: Partial; 58 | let send: Mock; 59 | let status: Mock; 60 | 61 | let customerObj: Partial; 62 | 63 | beforeEach(() => { 64 | req = { 65 | params: { 66 | id: "1" 67 | } 68 | }; 69 | 70 | ({ res, send, status } = createMockResponse()); 71 | 72 | customerObj = createCustomerObj(); 73 | }) 74 | 75 | it("should return 404 if no customer customerfound", async () => { 76 | (Customer.findOne as Mock).mockResolvedValueOnce(undefined); 77 | 78 | await getCustomerController(req as Request<{ id: string }>, res as Response); 79 | 80 | expect(status).toHaveBeenCalledWith(404); 81 | expect(send).toHaveBeenCalledWith("no customer found with that id"); 82 | }) 83 | 84 | it("should return customer if found", async () => { 85 | (Customer.findOne as Mock).mockResolvedValueOnce(customerObj); 86 | 87 | await getCustomerController(req as Request<{ id: string }>, res as Response); 88 | 89 | expect(send).toHaveBeenCalledWith(customerObj); 90 | }) 91 | }) 92 | 93 | describe("createCustomerController", () => { 94 | let req: Partial; 95 | let res: Partial; 96 | let send: Mock; 97 | 98 | let customerObj: Partial; 99 | 100 | beforeEach(() => { 101 | req = { 102 | body: { 103 | name: "Test Customer", 104 | isGold: false, 105 | phone: "1234567890" 106 | } 107 | }; 108 | 109 | ({ res, send } = createMockResponse()); 110 | 111 | customerObj = createCustomerObj(); 112 | }) 113 | 114 | it("should return customer exist if name found", async () => { 115 | (Customer.findOne as Mock).mockResolvedValueOnce(customerObj); 116 | 117 | await createCustomerController(req as Request<{ id: string }, {}, createCustomerInput>, res as Response); 118 | 119 | expect(send).toHaveBeenCalledWith("customer name already exist"); 120 | }) 121 | 122 | it("should return customer if created", async () => { 123 | (Customer.createQueryBuilder as Mock).mockReturnValueOnce({ 124 | insert: vi.fn(() => ({ 125 | into: vi.fn().mockReturnThis(), 126 | values: vi.fn().mockReturnThis(), 127 | returning: vi.fn().mockReturnThis(), 128 | execute: vi.fn().mockResolvedValueOnce({ 129 | raw: [customerObj] 130 | }) 131 | })), 132 | 133 | }); 134 | 135 | await createCustomerController(req as Request<{ id: string }, {}, createCustomerInput>, res as Response); 136 | 137 | expect(send).toHaveBeenCalledWith(customerObj); 138 | }) 139 | }) 140 | 141 | describe("updateCustomerController", () => { 142 | let req: Partial; 143 | let res: Partial; 144 | let send: Mock; 145 | 146 | let customerObj: Partial; 147 | 148 | beforeEach(() => { 149 | req = { 150 | params: { 151 | id: "1" 152 | }, 153 | body: { 154 | name: "Updated Customer", 155 | isGold: true, 156 | phone: "1234567890" 157 | } 158 | } as unknown as Partial; 159 | 160 | ({ res, send } = createMockResponse()); 161 | 162 | customerObj = { 163 | ...createCustomerObj(), 164 | name: "Updated Customer", 165 | isGold: true, 166 | } 167 | }); 168 | 169 | it("should return updated customer", async () => { 170 | (Customer.createQueryBuilder as Mock).mockReturnValueOnce({ 171 | update: vi.fn().mockReturnThis(), 172 | set: vi.fn().mockReturnThis(), 173 | where: vi.fn().mockReturnThis(), 174 | returning: vi.fn().mockReturnThis(), 175 | execute: vi.fn().mockResolvedValueOnce({ raw: [customerObj] }) 176 | }); 177 | 178 | await updateCustomerController(req as Request<{ id: string }, {}, createCustomerInput>, res as Response); 179 | 180 | expect(send).toHaveBeenCalledWith(customerObj); 181 | }) 182 | }); 183 | 184 | describe("deleteCustomerController", () => { 185 | let req: Partial; 186 | let res: Partial; 187 | let send: Mock; 188 | 189 | beforeEach(() => { 190 | req = { 191 | params: { 192 | id: "1" 193 | } 194 | }; 195 | 196 | ({ res, send } = createMockResponse()); 197 | }); 198 | 199 | it("should delete customer", async () => { 200 | await deleteCustomerController(req as Request<{id: string}>, res as Response); 201 | 202 | expect(send).toHaveBeenCalledWith({ deleted: true, message: "customer deleted succesfully" }); 203 | }); 204 | }); -------------------------------------------------------------------------------- /__tests__/controllers/movie.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, expect, Mock, vi, beforeEach} from 'vitest'; 2 | import {createMovieController, deleteMovieController, getMovieController, updateMovieController} from '../../src/controllers/movie'; 3 | import {Movie} from "../../src/database/entities/movie"; 4 | import {Genre} from "../../src/database/entities/genre"; 5 | import {Request, Response} from 'express'; 6 | import { createMovieInput, updateMovieInput } from 'src/validators/movie'; 7 | 8 | vi.mock("../../src/database/entities/movie", () => ({ 9 | Movie: { 10 | findOne: vi.fn(), 11 | create: vi.fn(()=>({ 12 | save: vi.fn() 13 | })), 14 | createQueryBuilder: vi.fn(() => ({ 15 | update: vi.fn().mockReturnThis(), 16 | delete: vi.fn().mockReturnThis(), 17 | from: vi.fn().mockReturnThis(), 18 | set: vi.fn().mockReturnThis(), 19 | where: vi.fn().mockReturnThis(), 20 | returning: vi.fn().mockReturnThis(), 21 | execute: vi.fn() 22 | })), 23 | }, 24 | })); 25 | 26 | vi.mock("../../src/database/entities/genre", () => ({ 27 | Genre: { 28 | find: vi.fn(), 29 | }, 30 | })); 31 | 32 | const createMockResponse = () => { 33 | const send = vi.fn(); 34 | const status = vi.fn(() => ({send})); 35 | const header = vi.fn(() => ({send})); 36 | 37 | return { 38 | res: { 39 | status, 40 | header, 41 | send, 42 | } as unknown as Partial, 43 | send, 44 | status, 45 | header, 46 | }; 47 | } 48 | 49 | const createMovieObj = (overrides = {}): Partial => ({ 50 | movie_id: 1, 51 | title: "Test Movie", 52 | genres: [], 53 | numberInStock: 1, 54 | dailyRentalRate: 1, 55 | createdAt: new Date(), 56 | updatedAt: new Date(), 57 | ...overrides, 58 | }); 59 | 60 | const createGenreObj = (overrides = {}): Partial => ({ 61 | name: "Test Genre", 62 | movies: [], 63 | createdAt: new Date(), 64 | updatedAt: new Date(), 65 | ...overrides, 66 | }); 67 | 68 | describe("createMovieController",()=>{ 69 | let req: Partial; 70 | let res: Partial; 71 | let send: Mock; 72 | let status: Mock; 73 | 74 | let movieObj: Partial; 75 | let genreObj1: Partial; 76 | let genreObj2: Partial; 77 | 78 | beforeEach(()=>{ 79 | req = { 80 | body: { 81 | title: "Test Movie", 82 | genreId: [1, 2], 83 | numberInStock: 1, 84 | dailyRentalRate: 1, 85 | } as createMovieInput 86 | }; 87 | 88 | ({res, send, status} = createMockResponse()); 89 | movieObj = createMovieObj(); 90 | genreObj1 = createGenreObj({genre_id: "1"}); 91 | genreObj2 = createGenreObj({genre_id: 2}); 92 | }); 93 | 94 | it('should return 404 if genere not found', async()=>{ 95 | (Genre.find as Mock).mockResolvedValueOnce([1]); 96 | 97 | await createMovieController(req as Request, res as Response); 98 | 99 | expect(status).toHaveBeenCalledWith(404); 100 | expect(send).toHaveBeenCalledWith("invalid genreId"); 101 | }); 102 | 103 | it('should create a new movie', async()=>{ 104 | (Genre.find as Mock).mockResolvedValueOnce([genreObj1, genreObj2]); 105 | (Movie.create as Mock).mockReturnValueOnce({ 106 | save: vi.fn(()=>movieObj) 107 | }); 108 | 109 | await createMovieController(req as Request, res as Response); 110 | 111 | expect(send).toHaveBeenCalledWith(movieObj); 112 | }); 113 | }) 114 | 115 | describe("getMovieController",()=>{ 116 | let req: Partial; 117 | let res: Partial; 118 | let send: Mock; 119 | let status:Mock; 120 | 121 | let movieObj: Partial; 122 | 123 | beforeEach(()=>{ 124 | req = { 125 | params: { 126 | id: "1", 127 | } 128 | } as Request<{id: string}, {}, {}>; 129 | 130 | ({res, send, status} = createMockResponse()); 131 | movieObj = createMovieObj(); 132 | }); 133 | 134 | it('should return 404 if movie not found',async()=>{ 135 | (Movie.findOne as Mock).mockResolvedValueOnce(null); 136 | 137 | await getMovieController(req as Request<{id: string}, {}, {}>, res as Response); 138 | 139 | expect(status).toHaveBeenCalledWith(404); 140 | expect(send).toHaveBeenCalledWith("no file with that id "); 141 | }) 142 | 143 | it('should return movie', async()=>{ 144 | (Movie.findOne as Mock).mockResolvedValueOnce(movieObj); 145 | 146 | await getMovieController(req as Request<{id: string}, {}, {}>, res as Response); 147 | 148 | expect(send).toHaveBeenCalledWith(movieObj); 149 | }) 150 | }); 151 | 152 | describe("updateMovieController",()=>{ 153 | let req: Partial; 154 | let res: Partial; 155 | let send: Mock; 156 | 157 | let movieObj: Partial; 158 | 159 | beforeEach(()=>{ 160 | req = { 161 | params: { 162 | id: "1", 163 | }, 164 | body: { 165 | title: "Updated Movie", 166 | numberInStock: 2, 167 | dailyRentalRate: 3, 168 | } as updateMovieInput 169 | }; 170 | 171 | ({res, send} = createMockResponse()); 172 | movieObj = { 173 | movie_id: 1, 174 | title: "Updated Movie", 175 | numberInStock: 2, 176 | dailyRentalRate: 3, 177 | createdAt: new Date(), 178 | updatedAt: new Date(), 179 | }; 180 | }); 181 | 182 | it("should update movie", async()=>{ 183 | (Movie.createQueryBuilder as Mock).mockReturnValueOnce({ 184 | update: vi.fn(()=>({ 185 | set: vi.fn().mockReturnThis(), 186 | where: vi.fn().mockReturnThis(), 187 | returning: vi.fn().mockReturnThis(), 188 | execute: vi.fn().mockResolvedValueOnce({ 189 | raw: [movieObj], 190 | affected: 1, 191 | }) 192 | })) 193 | }); 194 | 195 | await updateMovieController(req as Request<{id: string}, {}, updateMovieInput>, res as Response); 196 | 197 | expect(send).toHaveBeenCalledWith({raw: [movieObj], affected: 1}); 198 | }) 199 | }) 200 | 201 | describe("deleteMovieController", ()=>{ 202 | let req: Partial; 203 | let res: Partial; 204 | let send: Mock; 205 | 206 | beforeEach(()=>{ 207 | req = { 208 | params: { 209 | id: "1", 210 | } 211 | }; 212 | 213 | ({res, send} = createMockResponse()); 214 | }); 215 | 216 | it('should delete movie', async()=>{ 217 | (Movie.createQueryBuilder as Mock).mockReturnValueOnce({ 218 | delete: vi.fn().mockReturnThis(), 219 | from: vi.fn().mockReturnThis(), 220 | where: vi.fn().mockReturnThis(), 221 | execute: vi.fn(), 222 | }); 223 | 224 | await deleteMovieController(req as Request<{id: string}>, res as Response); 225 | 226 | expect(send).toHaveBeenCalledWith({deleted: true, message: "movie deleted succesfully"}); 227 | }); 228 | }) --------------------------------------------------------------------------------