diff --git a/server/express/jest.config.json b/server/express/jest.config.json new file mode 100644 index 0000000..4c7a843 --- /dev/null +++ b/server/express/jest.config.json @@ -0,0 +1,23 @@ +{ + "preset": "ts-jest", + "testEnvironment": "node", + "roots": ["/src", "/tests"], + "testMatch": [ + "**/__tests__/**/*.ts", + "**/?(*.)+(spec|test).ts" + ], + "transform": { + "^.+\\.ts$": "ts-jest" + }, + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/**/*.d.ts" + ], + "coverageDirectory": "coverage", + "coverageReporters": [ + "text", + "lcov", + "html" + ], + "setupFilesAfterEnv": ["/tests/setup.ts"] +} \ No newline at end of file diff --git a/server/express/package.json b/server/express/package.json index d3a2a4b..299ceb0 100644 --- a/server/express/package.json +++ b/server/express/package.json @@ -5,25 +5,29 @@ "license": "Apache-2.0", "author": "Jordan Smith", "type": "commonjs", - "main": "dist/app.ts", + "main": "dist/app.js", "scripts": { "build": "tsc", - "start": "node dist/app.js", + "start": "node dist/src/app.js", "dev": "ts-node src/app.ts", - "test-setup": "ts-node src/test-setup.ts", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "dependencies": { - "express": "^5.1.0", - "mongodb": "^6.3.0", + "cors": "^2.8.5", "dotenv": "^16.3.1", - "cors": "^2.8.5" + "express": "^5.1.0", + "mongodb": "^6.3.0" }, "devDependencies": { + "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jest": "^29.5.14", "@types/node": "^20.10.5", - "@types/cors": "^2.8.17", - "typescript": "^5.3.3", - "ts-node": "^10.9.2" + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" } } diff --git a/server/express/src/app.ts b/server/express/src/app.ts index 9fb7130..571ffd4 100644 --- a/server/express/src/app.ts +++ b/server/express/src/app.ts @@ -1,17 +1,21 @@ /** * Express.js Backend for MongoDB Sample MFlix Application - * + * * This application demonstrates MongoDB operations using the Node.js driver * with TypeScript. The code prioritizes readability and educational value * over performance optimization. */ -import express from 'express'; -import cors from 'cors'; -import dotenv from 'dotenv'; -import { closeDatabaseConnection, connectToDatabase, verifyRequirements } from './config/database'; -import { errorHandler } from './utils/errorHandler'; -import moviesRouter from './routes/movies'; +import express from "express"; +import cors from "cors"; +import dotenv from "dotenv"; +import { + closeDatabaseConnection, + connectToDatabase, + verifyRequirements, +} from "./config/database"; +import { errorHandler } from "./utils/errorHandler"; +import moviesRouter from "./routes/movies"; // Load environment variables from .env file // This must be called before any other imports that use environment variables @@ -25,37 +29,40 @@ const PORT = process.env.PORT || 3001; * Allows the frontend to communicate with this Express backend * In production, this should be configured to only allow specific origins */ -app.use(cors({ - origin: process.env.CORS_ORIGIN || 'http://localhost:3000', - credentials: true -})); +app.use( + cors({ + origin: process.env.CORS_ORIGIN || "http://localhost:3000", + credentials: true, + }) +); /** * Middleware Configuration * Express.json() parses incoming JSON requests and puts the parsed data in req.body * The limit is set to handle potentially large movie documents */ -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(express.json({ limit: "10mb" })); +app.use(express.urlencoded({ extended: true, limit: "10mb" })); /** * API Routes * All movie-related CRUD operations are handled by the movies router */ -app.use('/api/movies', moviesRouter); +app.use("/api/movies", moviesRouter); /** * Root Endpoint * Provides basic information about the API */ -app.get('/', (req, res) => { +app.get("/", (req, res) => { res.json({ - name: 'MongoDB Sample MFlix API', - version: '1.0.0', - description: 'Express.js backend demonstrating MongoDB operations with the sample_mflix dataset', + name: "MongoDB Sample MFlix API", + version: "1.0.0", + description: + "Express.js backend demonstrating MongoDB operations with the sample_mflix dataset", endpoints: { - movies: '/api/movies' - } + movies: "/api/movies", + }, }); }); @@ -72,27 +79,26 @@ app.use(errorHandler); */ async function startServer() { try { - console.log('Starting MongoDB Sample MFlix API...'); - + console.log("Starting MongoDB Sample MFlix API..."); + // Connect to MongoDB database - console.log('Connecting to MongoDB...'); + console.log("Connecting to MongoDB..."); await connectToDatabase(); - console.log('Connected to MongoDB successfully'); - + console.log("Connected to MongoDB successfully"); + // Verify that all required indexes and sample data exist - console.log('Verifying requirements (indexes and sample data)...'); + console.log("Verifying requirements (indexes and sample data)..."); await verifyRequirements(); - console.log('All requirements verified successfully'); - + console.log("All requirements verified successfully"); + // Start the Express server app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`API documentation available at http://localhost:${PORT}`); }); - } catch (error) { - console.error('Failed to start server:', error); - + console.error("Failed to start server:", error); + // Exit the process if we can't start properly // This ensures the application doesn't run in a broken state process.exit(1); @@ -103,17 +109,17 @@ async function startServer() { * Graceful Shutdown Handler * Ensures the application shuts down cleanly when terminated */ -process.on('SIGINT', () => { - console.log('\nReceived SIGINT. Shutting down...'); +process.on("SIGINT", () => { + console.log("\nReceived SIGINT. Shutting down..."); closeDatabaseConnection(); process.exit(0); }); -process.on('SIGTERM', () => { - console.log('\nReceived SIGTERM. Shutting down...'); +process.on("SIGTERM", () => { + console.log("\nReceived SIGTERM. Shutting down..."); closeDatabaseConnection(); process.exit(0); }); // Start the server -startServer(); \ No newline at end of file +startServer(); diff --git a/server/express/src/config/database.ts b/server/express/src/config/database.ts index aa43cbb..44cce5d 100644 --- a/server/express/src/config/database.ts +++ b/server/express/src/config/database.ts @@ -1,12 +1,12 @@ /** * Database Configuration and Connection Management - * + * * This module handles MongoDB connection setup using the Node.js driver * and implements pre-flight checks to ensure the application has all * necessary indexes and sample data. */ -import { MongoClient, Db, Collection, Document } from 'mongodb'; +import { MongoClient, Db, Collection, Document } from "mongodb"; let client: MongoClient; let database: Db; @@ -20,27 +20,26 @@ async function _connectToDatabase(): Promise { // Retrieve MongoDB connection string from environment variables const uri = process.env.MONGODB_URI; - + if (!uri) { throw new Error( - 'MONGODB_URI environment variable is not defined. Please check your .env file and ensure it contains a valid MongoDB connection string.' + "MONGODB_URI environment variable is not defined. Please check your .env file and ensure it contains a valid MongoDB connection string." ); } try { // Create new MongoDB client instance client = new MongoClient(uri); - + // Connect to MongoDB await client.connect(); - + // Get reference to the sample_mflix database - database = client.db('sample_mflix'); - + database = client.db("sample_mflix"); + console.log(`Connected to database: ${database.databaseName}`); - + return database; - } catch (error) { throw error; } @@ -49,30 +48,30 @@ async function _connectToDatabase(): Promise { let connect$: Promise; /** * Establishes connection to MongoDB by using the connection string from environment variables - * + * * @returns Promise - The connected database instance * @throws Error if connection fails or if MONGODB_URI is not provided */ export async function connectToDatabase(): Promise { - // connect$ only gets assigned exactly once on the first request, ensuring all subsequent requests use the same connect$ promise. - connect$ ??= _connectToDatabase(); - return await connect$; + // connect$ only gets assigned exactly once on the first request, ensuring all subsequent requests use the same connect$ promise. + connect$ ??= _connectToDatabase(); + return await connect$; } /** * Gets a reference to a specific collection in the database - * + * * @param collectionName - Name of the collection to access * @returns Collection instance * @throws Error if database is not connected */ -export function getCollection(collectionName: string): Collection { +export function getCollection( + collectionName: string +): Collection { if (!database) { - throw new Error( - 'Database not connected.' - ); + throw new Error("Database not connected."); } - + return database.collection(collectionName); } @@ -83,25 +82,24 @@ export function getCollection(collectionName: string): Colle export async function closeDatabaseConnection(): Promise { if (client) { await client.close(); - console.log('Database connection closed'); + console.log("Database connection closed"); } } /** * Verifies that all required indexes exist and sample data is present - * + * * If any requirements are missing, this function will attempt to create them. */ export async function verifyRequirements(): Promise { try { const db = await connectToDatabase(); - + // Check if the movies collection exists and has data await verifyMoviesCollection(db); - console.log('All database requirements verified successfully'); - + console.log("All database requirements verified successfully"); } catch (error) { - console.error('Requirements verification failed:', error); + console.error("Requirements verification failed:", error); throw error; } } @@ -110,12 +108,28 @@ export async function verifyRequirements(): Promise { * Verifies the movies collection and creates necessary indexes */ async function verifyMoviesCollection(db: Db): Promise { - const moviesCollection = db.collection('movies'); - + const moviesCollection = db.collection("movies"); + // Check if collection has documents const movieCount = await moviesCollection.estimatedDocumentCount(); - + if (movieCount === 0) { - console.warn('Movies collection is empty. Please ensure sample_mflix data is loaded.'); + console.warn( + "Movies collection is empty. Please ensure sample_mflix data is loaded." + ); + } + + // Create text search index on plot field for full-text search + try { + await moviesCollection.createIndex( + { plot: "text", title: "text", fullplot: "text" }, + { + name: "text_search_index", + background: true, + } + ); + console.log("Text search index created for movies collection"); + } catch (error) { + console.error("Could not create text search index:", error); } } diff --git a/server/express/src/controllers/movieController.ts b/server/express/src/controllers/movieController.ts index d016bcc..d1ef1f5 100644 --- a/server/express/src/controllers/movieController.ts +++ b/server/express/src/controllers/movieController.ts @@ -1,29 +1,490 @@ /** * Movie Controller + * + * This file contains all the business logic for movie operations. + * Each method demonstrates different MongoDB operations using the Node.js driver. + * + * Implemented operations: + * - insertOne() - Create a single movie + * - insertMany() - Create multiple movies + * - findOne() - Get a single movie by ID + * - find() - Get multiple movies with filtering and pagination + * - updateOne() - Update a single movie + * - updateMany() - Update multiple movies + * - deleteOne() - Delete a single movie + * - deleteMany() - Delete multiple movies + * - findOneAndDelete() - Find and delete a movie in one operation */ -import { Request, Response } from 'express'; -import { getCollection } from '../config/database'; -import { createSuccessResponse } from '../utils/errorHandler'; - +import { Request, Response } from "express"; +import { ObjectId, Sort } from "mongodb"; +import { getCollection } from "../config/database"; +import { + createErrorResponse, + createSuccessResponse, + validateRequiredFields, +} from "../utils/errorHandler"; +import { + Movie, + CreateMovieRequest, + UpdateMovieRequest, + RawSearchQuery, + MovieFilter, +} from "../types"; /** * GET /api/movies + * + * Retrieves multiple movies with optional filtering, sorting, and pagination. + * Demonstrates the find() operation with various query options. + * + * Query parameters: + * - q: Text search query (searches title, plot, fullplot) + * - genre: Filter by genre + * - year: Filter by year + * - minRating: Minimum IMDB rating + * - maxRating: Maximum IMDB rating + * - limit: Number of results (default: 20, max: 100) + * - skip: Number of documents to skip for pagination + * - sortBy: Field to sort by (default: title) + * - sortOrder: Sort direction - asc or desc (default: asc) */ export async function getAllMovies(req: Request, res: Response): Promise { - const moviesCollection = getCollection('movies'); - - try { - // Execute the find operation with all options - const movies = await moviesCollection - .find({}) - .limit(10) // TODO: Remove temp limit used for testing - .toArray(); - - // Return successful response - res.json(createSuccessResponse(movies, `Found ${movies.length} movies`)); - - } catch (error) { - throw error; - } -} \ No newline at end of file + const moviesCollection = getCollection("movies"); + + // Extract and validate query parameters + const { + q, + genre, + year, + minRating, + maxRating, + limit = "20", + skip = "0", + sortBy = "title", + sortOrder = "asc", + }: RawSearchQuery = req.query; + + // Build MongoDB query filter + // This demonstrates how to construct complex queries with multiple conditions + const filter: MovieFilter = {}; + + // Text search by using MongoDB's text index + // This requires the text index we created in the database verification + if (q) { + filter.$text = { $search: q }; + } + + // Genre filtering + if (genre) { + filter.genres = { $regex: new RegExp(genre, "i") }; + } + + // Year filtering + if (year) { + filter.year = parseInt(year); + } + + // Rating range filtering + // Demonstrates nested field queries (imdb.rating) + if (minRating || maxRating) { + filter["imdb.rating"] = {}; + if (minRating) { + filter["imdb.rating"].$gte = parseFloat(minRating); + } + if (maxRating) { + filter["imdb.rating"].$lte = parseFloat(maxRating); + } + } + + // Parse and validate pagination parms for invalid inputs + const limitNum = Math.min( + Math.max( + parseInt(limit) || 20, // Default to 20 if invalid + 1 // Min 1 result + ), + 100 // Cap at 100 results for performance + ); + const skipNum = Math.max( + parseInt(skip) || 0, // Default to 0 if invalid + 0 // skip must be positive number + ); + + // Build sort object + // Demonstrates dynamic sorting based on user input + const sort: Sort = { + [sortBy]: sortOrder === "desc" ? -1 : 1, + }; + + // Execute the find operation with all options + const movies = await moviesCollection + .find(filter) + .sort(sort) + .limit(limitNum) + .skip(skipNum) + .toArray(); + + // Return successful response + res.json(createSuccessResponse(movies, `Found ${movies.length} movies`)); +} + +/** + * GET /api/movies/:id + * + * Retrieves a single movie by its ObjectId. + * Demonstrates the findOne() operation. + */ +export async function getMovieById(req: Request, res: Response): Promise { + const { id } = req.params; + + // Validate ObjectId format + if (!ObjectId.isValid(id)) { + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); + return; + } + + const moviesCollection = getCollection("movies"); + + // Use findOne() to get a single document by _id + const movie = await moviesCollection.findOne({ _id: new ObjectId(id) }); + + if (!movie) { + res + .status(404) + .json(createErrorResponse("Movie not found", "MOVIE_NOT_FOUND")); + return; + } + + res.json(createSuccessResponse(movie, "Movie retrieved successfully")); +} + +/** + * POST /api/movies + * + * Creates a single new movie document. + * Demonstrates the insertOne() operation. + */ +export async function createMovie(req: Request, res: Response): Promise { + const movieData: CreateMovieRequest = req.body; + + // Validate required fields + // The title field is the minimum requirement for a movie + validateRequiredFields(movieData, ["title"]); + + const moviesCollection = getCollection("movies"); + + // Use insertOne() to create a single document + // This operation returns information about the insertion including the new _id + const result = await moviesCollection.insertOne(movieData); + + if (!result.acknowledged) { + throw new Error("Movie insertion was not acknowledged by the database"); + } + + // Retrieve the created document to return complete data + const createdMovie = await moviesCollection.findOne({ + _id: result.insertedId, + }); + + res + .status(201) + .json( + createSuccessResponse( + createdMovie, + `Movie '${movieData.title}' created successfully` + ) + ); +} + +/** + * POST /api/movies/batch + * + * Creates multiple movie documents in a single operation. + * Demonstrates the insertMany() operation. + */ +export async function createMoviesBatch( + req: Request, + res: Response +): Promise { + const moviesData: CreateMovieRequest[] = req.body; + + // Validate that we have an array of movies + if (!Array.isArray(moviesData) || moviesData.length === 0) { + res + .status(400) + .json( + createErrorResponse( + "Request body must be a non-empty array of movie objects", + "INVALID_INPUT" + ) + ); + return; + } + + // Validate each movie has required fields + moviesData.forEach((movie, index) => { + try { + validateRequiredFields(movie, ["title"]); + } catch (error) { + throw new Error( + `Movie at index ${index}: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + }); + + const moviesCollection = getCollection("movies"); + + // Use insertMany() to create multiple documents + const result = await moviesCollection.insertMany(moviesData); + + if (!result.acknowledged) { + throw new Error( + "Batch movie insertion was not acknowledged by the database" + ); + } + + res.status(201).json( + createSuccessResponse( + { + insertedCount: result.insertedCount, + insertedIds: result.insertedIds, + }, + `Successfully created ${result.insertedCount} movies` + ) + ); +} + +/** + * PUT /api/movies/:id + * + * Updates a single movie document. + * Demonstrates the updateOne() operation. + */ +export async function updateMovie(req: Request, res: Response): Promise { + const { id } = req.params; + const updateData: UpdateMovieRequest = req.body; + + // Validate ObjectId format + if (!ObjectId.isValid(id)) { + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); + return; + } + + // Ensure we have something to update + if (Object.keys(updateData).length === 0) { + res + .status(400) + .json(createErrorResponse("No update data provided", "NO_UPDATE_DATA")); + return; + } + + const moviesCollection = getCollection("movies"); + + // Use updateOne() to update a single document + // $set operator replaces the value of fields with specified values + const result = await moviesCollection.updateOne( + { _id: new ObjectId(id) }, + { $set: updateData } + ); + + if (result.matchedCount === 0) { + res + .status(404) + .json(createErrorResponse("Movie not found", "MOVIE_NOT_FOUND")); + return; + } + + // Retrieve the updated document to return complete data + const updatedMovie = await moviesCollection.findOne({ + _id: new ObjectId(id), + }); + + res.json( + createSuccessResponse( + updatedMovie, + `Movie updated successfully. Modified ${result.modifiedCount} field(s).` + ) + ); +} + +/** + * PATCH /api/movies + * + * Updates multiple movies based on a filter. + * Demonstrates the updateMany() operation. + */ +export async function updateMoviesBatch( + req: Request, + res: Response +): Promise { + const { filter, update } = req.body; + + // Validate input + if (!filter || !update) { + res + .status(400) + .json( + createErrorResponse( + "Both filter and update objects are required", + "MISSING_REQUIRED_FIELDS" + ) + ); + return; + } + + if (Object.keys(update).length === 0) { + res + .status(400) + .json( + createErrorResponse("Update object cannot be empty", "EMPTY_UPDATE") + ); + return; + } + + const moviesCollection = getCollection("movies"); + + // Use updateMany() to update multiple documents + // This is useful for bulk operations like updating all movies from a certain year + const result = await moviesCollection.updateMany(filter, { $set: update }); + + res.json( + createSuccessResponse( + { + matchedCount: result.matchedCount, + modifiedCount: result.modifiedCount, + }, + `Update operation completed. Matched ${result.matchedCount} documents, modified ${result.modifiedCount} documents.` + ) + ); +} + +/** + * DELETE /api/movies/:id + * + * Deletes a single movie document. + * Demonstrates the deleteOne() operation. + */ +export async function deleteMovie(req: Request, res: Response): Promise { + const { id } = req.params; + + // Validate ObjectId format + if (!ObjectId.isValid(id)) { + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); + return; + } + + const moviesCollection = getCollection("movies"); + + // Use deleteOne() to remove a single document + const result = await moviesCollection.deleteOne({ _id: new ObjectId(id) }); + + if (result.deletedCount === 0) { + res + .status(404) + .json(createErrorResponse("Movie not found", "MOVIE_NOT_FOUND")); + return; + } + + res.json( + createSuccessResponse( + { deletedCount: result.deletedCount }, + "Movie deleted successfully" + ) + ); +} + +/** + * DELETE /api/movies + * + * Deletes multiple movies based on a filter. + * Demonstrates the deleteMany() operation. + */ +export async function deleteMoviesBatch( + req: Request, + res: Response +): Promise { + const { filter } = req.body; + + // Validate input + if (!filter || Object.keys(filter).length === 0) { + res + .status(400) + .json( + createErrorResponse( + "Filter object is required and cannot be empty. This prevents accidental deletion of all documents.", + "MISSING_FILTER" + ) + ); + return; + } + + const moviesCollection = getCollection("movies"); + + // Use deleteMany() to remove multiple documents + // This operation is useful for cleanup tasks like removing all movies from a certain year + const result = await moviesCollection.deleteMany(filter); + + res.json( + createSuccessResponse( + { deletedCount: result.deletedCount }, + `Delete operation completed. Removed ${result.deletedCount} documents.` + ) + ); +} + +/** + * DELETE /api/movies/:id/find-and-delete + * + * Finds and deletes a movie in a single atomic operation. + * Demonstrates the findOneAndDelete() operation. + */ +export async function findAndDeleteMovie( + req: Request, + res: Response +): Promise { + const { id } = req.params; + + // Validate ObjectId format + if (!ObjectId.isValid(id)) { + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); + return; + } + + const moviesCollection = getCollection("movies"); + + // Use findOneAndDelete() to find and delete in a single atomic operation + // This is useful when you need to return the deleted document + // or ensure the document exists before deletion + const deletedMovie = await moviesCollection.findOneAndDelete({ + _id: new ObjectId(id), + }); + + if (!deletedMovie) { + res + .status(404) + .json(createErrorResponse("Movie not found", "MOVIE_NOT_FOUND")); + return; + } + + res.json( + createSuccessResponse(deletedMovie, "Movie found and deleted successfully") + ); +} diff --git a/server/express/src/routes/movies.ts b/server/express/src/routes/movies.ts index ecaac35..c8b214c 100644 --- a/server/express/src/routes/movies.ts +++ b/server/express/src/routes/movies.ts @@ -1,19 +1,99 @@ /** * Movies API Routes + * + * This module defines the routing endpoints for movie operations. + * + * Implemented operations: + * - insertOne() - Create a single movie + * - insertMany() - Create multiple movies + * - findOne() - Get a single movie by ID + * - find() - Get multiple movies with filtering and pagination + * - updateOne() - Update a single movie + * - updateMany() - Update multiple movies + * - deleteOne() - Delete a single movie + * - deleteMany() - Delete multiple movies + * - findOneAndDelete() - Find and delete a movie in one operation */ -import express from 'express'; -import { asyncHandler } from '../utils/errorHandler'; -import * as movieController from '../controllers/movieController'; +import express from "express"; +import { asyncHandler } from "../utils/errorHandler"; +import * as movieController from "../controllers/movieController"; const router = express.Router(); /** * GET /api/movies - * + * * Retrieves multiple movies with optional filtering, sorting, and pagination. * Demonstrates the find() operation with various query options. */ -router.get('/', asyncHandler(movieController.getAllMovies)); +router.get("/", asyncHandler(movieController.getAllMovies)); -export default router; \ No newline at end of file +/** + * GET /api/movies/:id + * + * Retrieves a single movie by its ObjectId. + * Demonstrates the findOne() operation. + */ +router.get("/:id", asyncHandler(movieController.getMovieById)); + +/** + * POST /api/movies + * + * Creates a single new movie document. + * Demonstrates the insertOne() operation. + */ +router.post("/", asyncHandler(movieController.createMovie)); + +/** + * POST /api/movies/batch + * + * Creates multiple movie documents in a single operation. + * Demonstrates the insertMany() operation. + */ +router.post("/batch", asyncHandler(movieController.createMoviesBatch)); + +/** + * PUT /api/movies/:id + * + * Updates a single movie document. + * Demonstrates the updateOne() operation. + */ +router.put("/:id", asyncHandler(movieController.updateMovie)); + +/** + * PATCH /api/movies + * + * Updates multiple movies based on a filter. + * Demonstrates the updateMany() operation. + */ +router.patch("/", asyncHandler(movieController.updateMoviesBatch)); + +/** + * DELETE /api/movies/:id/find-and-delete + * + * Finds and deletes a movie in a single atomic operation. + * Demonstrates the findOneAndDelete() operation. + */ +router.delete( + "/:id/find-and-delete", + asyncHandler(movieController.findAndDeleteMovie) +); + +/** + * DELETE /api/movies/:id + * + * Deletes a single movie document. + * Demonstrates the deleteOne() operation. + */ +router.delete("/:id", asyncHandler(movieController.deleteMovie)); + +/** + * DELETE /api/movies + * + * Deletes multiple movies based on a filter. + * Demonstrates the deleteMany() operation. + */ +router.delete("/", asyncHandler(movieController.deleteMoviesBatch)); + +export default router; diff --git a/server/express/src/types/index.ts b/server/express/src/types/index.ts index cc18a04..af5f127 100644 --- a/server/express/src/types/index.ts +++ b/server/express/src/types/index.ts @@ -1,15 +1,15 @@ /** * TypeScript Type Definitions for MongoDB Documents - * + * * These interfaces define the structure of documents in the sample_mflix database. * They help ensure type safety when working with MongoDB operations. */ -import { ObjectId } from 'mongodb'; +import { ObjectId } from "mongodb"; /** * Interface for Movie documents in the movies collection - * + * * This represents the structure of movie documents in the sample_mflix.movies collection. */ export interface Movie { @@ -75,7 +75,7 @@ export interface Theater { zipcode: string; }; geo: { - type: 'Point'; + type: "Point"; coordinates: [number, number]; // [longitude, latitude] }; }; @@ -133,19 +133,32 @@ export interface UpdateMovieRequest { } /** - * Interface for search query parameters + * Type for raw query parameters (Express passes all params as strings) */ -export interface SearchQuery { +export type RawSearchQuery = { q?: string; genre?: string; - year?: number; - minRating?: number; - maxRating?: number; - limit?: number; - skip?: number; + year?: string; + minRating?: string; + maxRating?: string; + limit?: string; + skip?: string; sortBy?: string; - sortOrder?: 'asc' | 'desc'; -} + sortOrder?: string; +}; + +/** + * Type for MongoDB filter objects used in movie queries + */ +export type MovieFilter = { + $text?: { $search: string }; + genres?: { $regex: RegExp }; + year?: number; + "imdb.rating"?: { + $gte?: number; + $lte?: number; + }; +}; export type SuccessResponse = { success: true; @@ -158,7 +171,7 @@ export type SuccessResponse = { total: number; pages: number; }; -} +}; export type ErrorResponse = { success: false; @@ -169,6 +182,6 @@ export type ErrorResponse = { details?: any; }; timestamp: string; -} +}; -export type ApiResponse = SuccessResponse | ErrorResponse; \ No newline at end of file +export type ApiResponse = SuccessResponse | ErrorResponse; diff --git a/server/express/src/utils/errorHandler.ts b/server/express/src/utils/errorHandler.ts index 5ffbac6..929cf29 100644 --- a/server/express/src/utils/errorHandler.ts +++ b/server/express/src/utils/errorHandler.ts @@ -1,13 +1,13 @@ /** * Error Handling Utilities - * + * * This module provides centralized error handling for the Express application. * It includes middleware for catching and formatting errors in a consistent way. */ -import { Request, Response, NextFunction } from 'express'; -import { MongoError } from 'mongodb'; -import { SuccessResponse, ErrorResponse } from '../types'; +import { Request, Response, NextFunction } from "express"; +import { MongoError } from "mongodb"; +import { SuccessResponse, ErrorResponse } from "../types"; /** * Custom ValidationError class for field validation errors @@ -15,16 +15,16 @@ import { SuccessResponse, ErrorResponse } from '../types'; export class ValidationError extends Error { constructor(message: string) { super(message); - this.name = 'ValidationError'; + this.name = "ValidationError"; } } /** * Global error handling middleware - * + * * This middleware catches all unhandled errors and returns a consistent * error response format. It should be the last middleware in the chain. - * + * * @param err - The error that was thrown * @param req - Express request object * @param res - Express response object @@ -38,30 +38,30 @@ export function errorHandler( ): void { // Log the error for debugging purposes // In production, we recommend using a logging service - console.error('Error occurred:', { + console.error("Error occurred:", { message: err.message, stack: err.stack, url: req.url, method: req.method, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); // Determine the appropriate HTTP status code and error message const errorDetails = parseErrorDetails(err); - + const response: ErrorResponse = createErrorResponse( errorDetails.message, errorDetails.code, errorDetails.details ); - + // Send the error response res.status(errorDetails.statusCode).json(response); } /** * Creates a standardized error response based on the error type - * + * * @param err - The error to process * @returns Object containing status code, message, and optional details */ @@ -79,60 +79,58 @@ function parseErrorDetails(err: Error): { switch (err.code) { case 11000: return { - message: 'Duplicate key error', - code: 'DUPLICATE_KEY', - details: 'A document with this data already exists', - statusCode: 409 + message: "Duplicate key error", + code: "DUPLICATE_KEY", + details: "A document with this data already exists", + statusCode: 409, }; case 121: // Document validation failed return { statusCode: 400, - message: 'Document validation failed', - code: 'DOCUMENT_VALIDATION_ERROR', - details: err.message + message: "Document validation failed", + code: "DOCUMENT_VALIDATION_ERROR", + details: err.message, }; default: return { - message: 'Database error', - code: 'DATABASE_ERROR', + message: "Database error", + code: "DATABASE_ERROR", details: err.code, - statusCode: 500 + statusCode: 500, }; } } // Validation errors - if (err.name === 'ValidationError') { + if (err.name === "ValidationError") { return { - message: 'Validation failed', - code: 'VALIDATION_ERROR', + message: "Validation failed", + code: "VALIDATION_ERROR", details: err.message, - statusCode: 400 + statusCode: 400, }; } // Default error handling return { - message: err.message || 'Internal server error', - code: 'INTERNAL_ERROR', - statusCode: 500 + message: err.message || "Internal server error", + code: "INTERNAL_ERROR", + statusCode: 500, }; } - - /** * Async wrapper function for route handlers - * + * * This function wraps async route handlers to automatically catch * and forward any errors to the error handling middleware. - * + * * Usage: * app.get('/route', asyncHandler(async (req, res) => { * // Your async code here * })); - * + * * @param fn - Async route handler function * @returns Express middleware function */ @@ -141,63 +139,75 @@ export function asyncHandler( ) { return (req: Request, res: Response, next: NextFunction) => { try { - fn(req, res, next).catch(next); + fn(req, res, next).catch(next); } catch (error) { - next(error); + next(error); } }; } /** * Creates a standardized success response - * + * * @param data - The data to include in the response * @param message - Optional success message * @returns Standardized success response object */ -export function createSuccessResponse(data: T, message?: string): SuccessResponse { +export function createSuccessResponse( + data: T, + message?: string +): SuccessResponse { return { success: true, - message: message || 'Operation completed successfully', + message: message || "Operation completed successfully", data, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; } /** * Creates a standardized error response - * + * * @param message - Error message * @param code - Optional error code * @param details - Optional error details * @returns Standardized error response object */ -export function createErrorResponse(message: string, code?: string, details?: any): ErrorResponse { +export function createErrorResponse( + message: string, + code?: string, + details?: any +): ErrorResponse { return { success: false, message, error: { message, code, - details + details, }, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; } /** * Validates that required fields are present in the request body - * + * * @param body - Request body object * @param requiredFields - Array of required field names * @throws ValidationError if any required fields are missing */ -export function validateRequiredFields(body: any, requiredFields: string[]): void { - const missingFields = requiredFields.filter(field => - body[field] == null || body[field] === '' +export function validateRequiredFields( + body: any, + requiredFields: string[] +): void { + const missingFields = requiredFields.filter( + (field) => body[field] == null || body[field] === "" ); - + if (missingFields.length > 0) { - throw new ValidationError(`Missing required fields: ${missingFields.join(', ')}`); + throw new ValidationError( + `Missing required fields: ${missingFields.join(", ")}` + ); } -} \ No newline at end of file +} diff --git a/server/express/tests/controllers/movieController.test.ts b/server/express/tests/controllers/movieController.test.ts new file mode 100644 index 0000000..3309932 --- /dev/null +++ b/server/express/tests/controllers/movieController.test.ts @@ -0,0 +1,681 @@ +/** + * Unit Tests for Movie Controller + * + * These tests verify the business logic of movie controller functions + * without requiring actual database connections. + */ + +import { Request, Response } from "express"; +import { ObjectId } from "mongodb"; + +// Test Data Constants +const TEST_MOVIE_ID = "507f1f77bcf86cd799439011"; +const INVALID_MOVIE_ID = "invalid-id"; + +const SAMPLE_MOVIE = { + _id: TEST_MOVIE_ID, + title: "Test Movie", + year: 2024, + plot: "A test movie", + genres: ["Action"], +}; + +const SAMPLE_MOVIES = [ + { + _id: TEST_MOVIE_ID, + title: "Test Movie 1", + year: 2024, + plot: "A test movie", + genres: ["Action"], + }, + { + _id: TEST_MOVIE_ID + "-b", + title: "Test Movie 2", + year: 2024, + plot: "Another test movie", + genres: ["Comedy"], + }, +]; + +// Create mock collection methods +const mockFind = jest.fn(); +const mockFindOne = jest.fn(); +const mockInsertOne = jest.fn(); +const mockInsertMany = jest.fn(); +const mockUpdateOne = jest.fn(); +const mockUpdateMany = jest.fn(); +const mockDeleteOne = jest.fn(); +const mockDeleteMany = jest.fn(); +const mockFindOneAndDelete = jest.fn(); +const mockToArray = jest.fn(); + +// Create mock database module +const mockGetCollection = jest.fn(() => ({ + find: mockFind.mockReturnValue({ + toArray: mockToArray, + limit: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + }), + findOne: mockFindOne, + insertOne: mockInsertOne, + insertMany: mockInsertMany, + updateOne: mockUpdateOne, + updateMany: mockUpdateMany, + deleteOne: mockDeleteOne, + deleteMany: mockDeleteMany, + findOneAndDelete: mockFindOneAndDelete, +})); + +// Mock the database module +jest.mock("../../src/config/database", () => ({ + getCollection: mockGetCollection, +})); + +// Mock the error handler utilities +const mockCreateSuccessResponse = jest.fn((data: any, message: string) => ({ + success: true, + message, + data, + timestamp: "2024-01-01T00:00:00.000Z", +})); + +const mockCreateErrorResponse = jest.fn( + (message: string, code?: string, details?: any) => ({ + success: false, + message, + error: { + message, + code, + details, + }, + timestamp: "2024-01-01T00:00:00.000Z", + }) +); + +const mockValidateRequiredFields = jest.fn(); + +jest.mock("../../src/utils/errorHandler", () => ({ + createSuccessResponse: mockCreateSuccessResponse, + createErrorResponse: mockCreateErrorResponse, + validateRequiredFields: mockValidateRequiredFields, +})); + +// Import controller methods after mocks +import { + getAllMovies, + getMovieById, + createMovie, + createMoviesBatch, + updateMovie, + updateMoviesBatch, + deleteMovie, + deleteMoviesBatch, + findAndDeleteMovie, +} from "../../src/controllers/movieController"; + +// Helper Functions +function createMockRequest(overrides: Partial = {}): Partial { + return { + query: {}, + params: {}, + body: {}, + ...overrides, + }; +} + +function createMockResponse(): { + mockJson: jest.Mock; + mockStatus: jest.Mock; + mockResponse: Partial; +} { + const mockJson = jest.fn(); + const mockStatus = jest.fn().mockReturnThis(); + + const mockResponse = { + json: mockJson, + status: mockStatus, + setHeader: jest.fn(), + }; + + return { mockJson, mockStatus, mockResponse }; +} + +function expectSuccessResponse( + mockCreateSuccessResponse: jest.Mock, + data: any, + message: string +) { + expect(mockCreateSuccessResponse).toHaveBeenCalledWith(data, message); +} + +function expectErrorResponse( + mockStatus: jest.Mock, + mockJson: jest.Mock, + statusCode: number, + errorMessage: string, + errorCode: string +) { + expect(mockStatus).toHaveBeenCalledWith(statusCode); + expect(mockCreateErrorResponse).toHaveBeenCalledWith(errorMessage, errorCode); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + message: errorMessage, + error: { + message: errorMessage, + code: errorCode, + details: undefined, + }, + timestamp: "2024-01-01T00:00:00.000Z", + }); +} + +describe("Movie Controller Tests", () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockJson: jest.Mock; + let mockStatus: jest.Mock; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Setup fresh response mock + const responseMocks = createMockResponse(); + mockJson = responseMocks.mockJson; + mockStatus = responseMocks.mockStatus; + mockResponse = responseMocks.mockResponse; + + mockRequest = createMockRequest(); + }); + + describe("getAllMovies", () => { + it("should successfully retrieve movies", async () => { + mockToArray.mockResolvedValue(SAMPLE_MOVIES); + + await getAllMovies(mockRequest as Request, mockResponse as Response); + + expect(mockGetCollection).toHaveBeenCalledWith("movies"); + expect(mockFind).toHaveBeenCalledWith({}); + expectSuccessResponse( + mockCreateSuccessResponse, + SAMPLE_MOVIES, + "Found 2 movies" + ); + expect(mockJson).toHaveBeenCalledWith({ + success: true, + message: "Found 2 movies", + data: SAMPLE_MOVIES, + timestamp: "2024-01-01T00:00:00.000Z", + }); + }); + + it("should handle empty results", async () => { + mockToArray.mockResolvedValue([]); + + await getAllMovies(mockRequest as Request, mockResponse as Response); + + expectSuccessResponse(mockCreateSuccessResponse, [], "Found 0 movies"); + }); + + it("should handle database errors", async () => { + const errorMessage = "Database connection failed"; + mockToArray.mockRejectedValue(new Error(errorMessage)); + + await expect( + getAllMovies(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow(errorMessage); + }); + + it("should handle query parameters for filtering", async () => { + const testMovies = [{ _id: TEST_MOVIE_ID, title: "Action Movie" }]; + mockRequest.query = { + genre: "Action", + year: "2024", + minRating: "7.0", + limit: "10", + sortBy: "year", + sortOrder: "desc", + }; + mockToArray.mockResolvedValue(testMovies); + + await getAllMovies(mockRequest as Request, mockResponse as Response); + + expect(mockFind).toHaveBeenCalledWith({ + genres: { $regex: new RegExp("Action", "i") }, + year: 2024, + "imdb.rating": { $gte: 7.0 }, + }); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + testMovies, + "Found 1 movies" + ); + }); + }); + + describe("getMovieById", () => { + it("should successfully retrieve a movie by valid ID", async () => { + mockRequest = createMockRequest({ params: { id: TEST_MOVIE_ID } }); + mockFindOne.mockResolvedValue(SAMPLE_MOVIE); + + await getMovieById(mockRequest as Request, mockResponse as Response); + + expect(mockGetCollection).toHaveBeenCalledWith("movies"); + expect(mockFindOne).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_MOVIE_ID), + }); + expectSuccessResponse( + mockCreateSuccessResponse, + SAMPLE_MOVIE, + "Movie retrieved successfully" + ); + expect(mockJson).toHaveBeenCalled(); + }); + + it("should return 400 for invalid ObjectId format", async () => { + mockRequest = createMockRequest({ params: { id: INVALID_MOVIE_ID } }); + + await getMovieById(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Invalid movie ID format", + "INVALID_OBJECT_ID" + ); + }); + + it("should return 404 when movie not found", async () => { + mockRequest = createMockRequest({ params: { id: TEST_MOVIE_ID } }); + mockFindOne.mockResolvedValue(null); + + await getMovieById(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 404, + "Movie not found", + "MOVIE_NOT_FOUND" + ); + }); + + it("should handle database errors", async () => { + mockRequest = createMockRequest({ params: { id: TEST_MOVIE_ID } }); + const errorMessage = "Database error"; + mockFindOne.mockRejectedValue(new Error(errorMessage)); + + await expect( + getMovieById(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow(errorMessage); + }); + }); + + describe("createMovie", () => { + it("should successfully create a movie", async () => { + const movieData = { title: "New Movie", year: 2024 }; + const insertResult = { acknowledged: true, insertedId: new ObjectId() }; + const createdMovie = { _id: insertResult.insertedId, ...movieData }; + + mockRequest.body = movieData; + mockInsertOne.mockResolvedValue(insertResult); + mockFindOne.mockResolvedValue(createdMovie); + + await createMovie(mockRequest as Request, mockResponse as Response); + + expect(mockValidateRequiredFields).toHaveBeenCalledWith(movieData, [ + "title", + ]); + expect(mockInsertOne).toHaveBeenCalledWith(movieData); + expect(mockFindOne).toHaveBeenCalledWith({ + _id: insertResult.insertedId, + }); + expect(mockStatus).toHaveBeenCalledWith(201); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + createdMovie, + "Movie 'New Movie' created successfully" + ); + }); + + it("should handle validation errors", async () => { + const movieData = { + /* missing title */ + }; + mockRequest.body = movieData; + + const error = new Error("Missing required fields: title"); + mockValidateRequiredFields.mockImplementation(() => { + throw error; + }); + + await expect( + createMovie(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow("Missing required fields: title"); + }); + + it("should handle insert acknowledgment failure", async () => { + const movieData = { title: "Test Movie" }; + mockRequest.body = movieData; + mockValidateRequiredFields.mockImplementation(() => {}); // Don't throw validation error + mockInsertOne.mockResolvedValue({ acknowledged: false }); + + await expect( + createMovie(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow("Movie insertion was not acknowledged by the database"); + }); + }); + + describe("createMoviesBatch", () => { + it("should successfully create multiple movies", async () => { + const moviesData = [{ title: "Movie 1" }, { title: "Movie 2" }]; + const insertResult = { + acknowledged: true, + insertedCount: 2, + insertedIds: [new ObjectId(), new ObjectId()], + }; + + mockRequest.body = moviesData; + mockValidateRequiredFields.mockImplementation(() => {}); // Don't throw validation error + mockInsertMany.mockResolvedValue(insertResult); + + await createMoviesBatch(mockRequest as Request, mockResponse as Response); + + expect(mockInsertMany).toHaveBeenCalledWith(moviesData); + expect(mockStatus).toHaveBeenCalledWith(201); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { + insertedCount: 2, + insertedIds: insertResult.insertedIds, + }, + "Successfully created 2 movies" + ); + }); + + it("should return 400 for invalid input (not an array)", async () => { + mockRequest.body = { title: "Single Movie" }; + + await createMoviesBatch(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Request body must be a non-empty array of movie objects", + "INVALID_INPUT" + ); + }); + + it("should return 400 for empty array", async () => { + mockRequest.body = []; + + await createMoviesBatch(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(400); + }); + }); + + describe("updateMovie", () => { + it("should successfully update a movie", async () => { + const updateData = { title: "Updated Movie" }; + const updateResult = { matchedCount: 1, modifiedCount: 1 }; + const updatedMovie = { _id: TEST_MOVIE_ID, title: "Updated Movie" }; + + mockRequest.params = { id: TEST_MOVIE_ID }; + mockRequest.body = updateData; + mockUpdateOne.mockResolvedValue(updateResult); + mockFindOne.mockResolvedValue(updatedMovie); + + await updateMovie(mockRequest as Request, mockResponse as Response); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_MOVIE_ID) }, + { $set: updateData } + ); + expect(mockFindOne).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_MOVIE_ID), + }); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + updatedMovie, + "Movie updated successfully. Modified 1 field(s)." + ); + }); + + it("should return 400 for invalid ObjectId", async () => { + mockRequest.params = { id: INVALID_MOVIE_ID }; + mockRequest.body = { title: "Updated" }; + + await updateMovie(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(400); + }); + + it("should return 400 for empty update data", async () => { + mockRequest.params = { id: TEST_MOVIE_ID }; + mockRequest.body = {}; + + await updateMovie(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "No update data provided", + "NO_UPDATE_DATA" + ); + }); + + it("should return 404 when movie not found", async () => { + mockRequest.params = { id: TEST_MOVIE_ID }; + mockRequest.body = { title: "Updated" }; + mockUpdateOne.mockResolvedValue({ matchedCount: 0, modifiedCount: 0 }); + + await updateMovie(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(404); + }); + }); + + describe("deleteMovie", () => { + it("should successfully delete a movie", async () => { + const deleteResult = { deletedCount: 1 }; + + mockRequest.params = { id: TEST_MOVIE_ID }; + mockDeleteOne.mockResolvedValue(deleteResult); + + await deleteMovie(mockRequest as Request, mockResponse as Response); + + expect(mockDeleteOne).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_MOVIE_ID), + }); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { deletedCount: 1 }, + "Movie deleted successfully" + ); + }); + + it("should return 400 for invalid ObjectId", async () => { + mockRequest.params = { id: INVALID_MOVIE_ID }; + + await deleteMovie(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(400); + }); + + it("should return 404 when movie not found", async () => { + mockRequest.params = { id: TEST_MOVIE_ID }; + mockDeleteOne.mockResolvedValue({ deletedCount: 0 }); + + await deleteMovie(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 404, + "Movie not found", + "MOVIE_NOT_FOUND" + ); + }); + + it("should handle database errors", async () => { + mockRequest.params = { id: TEST_MOVIE_ID }; + const errorMessage = "Database error"; + mockDeleteOne.mockRejectedValue(new Error(errorMessage)); + + await expect( + deleteMovie(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow(errorMessage); + }); + }); + + describe("updateMoviesBatch", () => { + it("should successfully update multiple movies", async () => { + const filter = { year: 2023 }; + const update = { genre: "Updated Genre" }; + const updateResult = { matchedCount: 5, modifiedCount: 3 }; + + mockRequest.body = { filter, update }; + mockUpdateMany.mockResolvedValue(updateResult); + + await updateMoviesBatch(mockRequest as Request, mockResponse as Response); + + expect(mockUpdateMany).toHaveBeenCalledWith(filter, { $set: update }); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { + matchedCount: 5, + modifiedCount: 3, + }, + "Update operation completed. Matched 5 documents, modified 3 documents." + ); + }); + + it("should return 400 when filter is missing", async () => { + mockRequest.body = { update: { title: "Updated" } }; + + await updateMoviesBatch(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Both filter and update objects are required", + "MISSING_REQUIRED_FIELDS" + ); + }); + + it("should return 400 when update is empty", async () => { + mockRequest.body = { filter: { year: 2023 }, update: {} }; + + await updateMoviesBatch(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Update object cannot be empty", + "EMPTY_UPDATE" + ); + }); + }); + + describe("deleteMoviesBatch", () => { + it("should successfully delete multiple movies", async () => { + const filter = { year: { $lt: 2000 } }; + const deleteResult = { deletedCount: 10 }; + + mockRequest.body = { filter }; + mockDeleteMany.mockResolvedValue(deleteResult); + + await deleteMoviesBatch(mockRequest as Request, mockResponse as Response); + + expect(mockDeleteMany).toHaveBeenCalledWith(filter); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { deletedCount: 10 }, + "Delete operation completed. Removed 10 documents." + ); + }); + + it("should return 400 when filter is missing", async () => { + mockRequest.body = {}; + + await deleteMoviesBatch(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Filter object is required and cannot be empty. This prevents accidental deletion of all documents.", + "MISSING_FILTER" + ); + }); + + it("should return 400 when filter is empty", async () => { + mockRequest.body = { filter: {} }; + + await deleteMoviesBatch(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(400); + }); + }); + + describe("findAndDeleteMovie", () => { + it("should successfully find and delete a movie", async () => { + const deletedMovie = { _id: TEST_MOVIE_ID, title: "Deleted Movie" }; + + mockRequest.params = { id: TEST_MOVIE_ID }; + mockFindOneAndDelete.mockResolvedValue(deletedMovie); + + await findAndDeleteMovie( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockFindOneAndDelete).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_MOVIE_ID), + }); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + deletedMovie, + "Movie found and deleted successfully" + ); + }); + + it("should return 400 for invalid ObjectId", async () => { + mockRequest.params = { id: INVALID_MOVIE_ID }; + + await findAndDeleteMovie( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockStatus).toHaveBeenCalledWith(400); + }); + + it("should return 404 when movie not found", async () => { + mockRequest.params = { id: TEST_MOVIE_ID }; + mockFindOneAndDelete.mockResolvedValue(null); + + await findAndDeleteMovie( + mockRequest as Request, + mockResponse as Response + ); + + expectErrorResponse( + mockStatus, + mockJson, + 404, + "Movie not found", + "MOVIE_NOT_FOUND" + ); + }); + + it("should handle database errors", async () => { + mockRequest.params = { id: TEST_MOVIE_ID }; + const errorMessage = "Database error"; + mockFindOneAndDelete.mockRejectedValue(new Error(errorMessage)); + + await expect( + findAndDeleteMovie(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow(errorMessage); + }); + }); +}); diff --git a/server/express/tests/setup.ts b/server/express/tests/setup.ts new file mode 100644 index 0000000..df081e6 --- /dev/null +++ b/server/express/tests/setup.ts @@ -0,0 +1,23 @@ +/** + * Jest Test Setup + * + * This file runs before all tests and sets up global test configuration, + * including environment variables and mock implementations. + */ + +// Set test environment variables +process.env.NODE_ENV = "test"; +process.env.MONGODB_URI = "mongodb://localhost:27017/test_sample_mflix"; +process.env.PORT = "3002"; + +// Increase timeout for database operations in tests +jest.setTimeout(30000); + +// Global test utilities can be added here +global.console = { + ...console, + // Suppress console.log in tests unless needed for debugging + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; diff --git a/server/express/tsconfig.json b/server/express/tsconfig.json index 7fd5221..3afae01 100644 --- a/server/express/tsconfig.json +++ b/server/express/tsconfig.json @@ -4,7 +4,6 @@ "module": "commonjs", "lib": ["ES2020"], "outDir": "./dist", - "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -12,10 +11,12 @@ "resolveJsonModule": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "types": ["jest", "node"] }, "include": [ - "src/**/*" + "src/**/*", + "tests/**/*" ], "exclude": [ "node_modules",