From 8f7c4a298c844a96fffe628413bf49ef7828d5ff Mon Sep 17 00:00:00 2001 From: Jordan Smith Date: Mon, 13 Oct 2025 14:52:25 -0400 Subject: [PATCH 1/3] feat: implement CRUD API w/ testing --- server/express/jest.config.json | 23 + server/express/package.json | 24 +- server/express/src/config/database.ts | 14 + .../src/controllers/movieController.ts | 528 +++++++++++++- server/express/src/routes/movies.ts | 79 ++- .../tests/controllers/movieController.test.ts | 645 ++++++++++++++++++ server/express/tests/setup.ts | 23 + server/express/tsconfig.json | 7 +- 8 files changed, 1324 insertions(+), 19 deletions(-) create mode 100644 server/express/jest.config.json create mode 100644 server/express/tests/controllers/movieController.test.ts create mode 100644 server/express/tests/setup.ts 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/config/database.ts b/server/express/src/config/database.ts index aa43cbb..4eaab97 100644 --- a/server/express/src/config/database.ts +++ b/server/express/src/config/database.ts @@ -118,4 +118,18 @@ async function verifyMoviesCollection(db: Db): Promise { if (movieCount === 0) { 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.log('Could not create text search index.'); + } } diff --git a/server/express/src/controllers/movieController.ts b/server/express/src/controllers/movieController.ts index d016bcc..c8069af 100644 --- a/server/express/src/controllers/movieController.ts +++ b/server/express/src/controllers/movieController.ts @@ -1,29 +1,547 @@ /** * 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 { ObjectId } from 'mongodb'; import { getCollection } from '../config/database'; -import { createSuccessResponse } from '../utils/errorHandler'; - +import { createSuccessResponse, validateRequiredFields } from '../utils/errorHandler'; +import { + Movie, + CreateMovieRequest, + UpdateMovieRequest +} 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'); + + // Extract and validate query parameters + const { + q, + genre, + year, + minRating, + maxRating, + limit = '20', + skip = '0', + sortBy = 'title', + sortOrder = 'asc' + } = req.query as { [key: string]: string }; + + // Build MongoDB query filter + // This demonstrates how to construct complex queries with multiple conditions + const filter: any = {}; + + // 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 pagination parameters + const limitNum = Math.min(parseInt(limit), 100); // Cap at 100 for performance + const skipNum = parseInt(skip); + + // Build sort object + // Demonstrates dynamic sorting based on user input + const sort: any = {}; + sort[sortBy] = sortOrder === 'desc' ? -1 : 1; try { // Execute the find operation with all options const movies = await moviesCollection - .find({}) - .limit(10) // TODO: Remove temp limit used for testing + .find(filter) + .sort(sort) + .limit(limitNum) + .skip(skipNum) .toArray(); // Return successful response res.json(createSuccessResponse(movies, `Found ${movies.length} movies`)); - + } catch (error) { throw error; } +} + +/** + * 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({ + success: false, + error: { + message: 'Invalid movie ID format', + code: 'INVALID_OBJECT_ID' + }, + timestamp: new Date().toISOString() + }); + return; + } + + const moviesCollection = getCollection('movies'); + + try { + // Use findOne() to get a single document by _id + const movie = await moviesCollection.findOne({ _id: new ObjectId(id) }); + + if (!movie) { + res.status(404).json({ + success: false, + error: { + message: 'Movie not found', + code: 'MOVIE_NOT_FOUND' + }, + timestamp: new Date().toISOString() + }); + return; + } + + res.json(createSuccessResponse(movie, 'Movie retrieved successfully')); + + } catch (error) { + throw new Error(`Failed to retrieve movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * 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'); + + try { + // Prepare the document for insertion + // Here you can add metadata or other fields that might be necessary + const movieDocument: Partial = { + ...movieData + }; + + // Use insertOne() to create a single document + // This operation returns information about the insertion including the new _id + const result = await moviesCollection.insertOne(movieDocument); + + 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` + ) + ); + + } catch (error) { + throw new Error(`Failed to create movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * 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({ + success: false, + error: { + message: 'Request body must be a non-empty array of movie objects', + code: 'INVALID_INPUT' + }, + timestamp: new Date().toISOString() + }); + 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'); + + try { + // 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` + ) + ); + + } catch (error) { + throw new Error(`Failed to create movies: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * 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({ + success: false, + error: { + message: 'Invalid movie ID format', + code: 'INVALID_OBJECT_ID' + }, + timestamp: new Date().toISOString() + }); + return; + } + + // Ensure we have something to update + if (Object.keys(updateData).length === 0) { + res.status(400).json({ + success: false, + error: { + message: 'No update data provided', + code: 'NO_UPDATE_DATA' + }, + timestamp: new Date().toISOString() + }); + return; + } + + const moviesCollection = getCollection('movies'); + + try { + // 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({ + success: false, + error: { + message: 'Movie not found', + code: 'MOVIE_NOT_FOUND' + }, + timestamp: new Date().toISOString() + }); + 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).` + ) + ); + + } catch (error) { + throw new Error(`Failed to update movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * 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({ + success: false, + error: { + message: 'Both filter and update objects are required', + code: 'MISSING_REQUIRED_FIELDS' + }, + timestamp: new Date().toISOString() + }); + return; + } + + if (Object.keys(update).length === 0) { + res.status(400).json({ + success: false, + error: { + message: 'Update object cannot be empty', + code: 'EMPTY_UPDATE' + }, + timestamp: new Date().toISOString() + }); + return; + } + + const moviesCollection = getCollection('movies'); + + try { + // 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.` + ) + ); + + } catch (error) { + throw new Error(`Failed to update movies: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * 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({ + success: false, + error: { + message: 'Invalid movie ID format', + code: 'INVALID_OBJECT_ID' + }, + timestamp: new Date().toISOString() + }); + return; + } + + const moviesCollection = getCollection('movies'); + + try { + // Use deleteOne() to remove a single document + const result = await moviesCollection.deleteOne({ _id: new ObjectId(id) }); + + if (result.deletedCount === 0) { + res.status(404).json({ + success: false, + error: { + message: 'Movie not found', + code: 'MOVIE_NOT_FOUND' + }, + timestamp: new Date().toISOString() + }); + return; + } + + res.json( + createSuccessResponse( + { deletedCount: result.deletedCount }, + 'Movie deleted successfully' + ) + ); + + } catch (error) { + throw new Error(`Failed to delete movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * 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({ + success: false, + error: { + message: 'Filter object is required and cannot be empty. This prevents accidental deletion of all documents.', + code: 'MISSING_FILTER' + }, + timestamp: new Date().toISOString() + }); + return; + } + + const moviesCollection = getCollection('movies'); + + try { + // 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.` + ) + ); + + } catch (error) { + throw new Error(`Failed to delete movies: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * 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({ + success: false, + error: { + message: 'Invalid movie ID format', + code: 'INVALID_OBJECT_ID' + }, + timestamp: new Date().toISOString() + }); + return; + } + + const moviesCollection = getCollection('movies'); + + try { + // 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({ + success: false, + error: { + message: 'Movie not found', + code: 'MOVIE_NOT_FOUND' + }, + timestamp: new Date().toISOString() + }); + return; + } + + res.json( + createSuccessResponse( + deletedMovie, + 'Movie found and deleted successfully' + ) + ); + + } catch (error) { + throw new Error(`Failed to find and delete movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + } } \ No newline at end of file diff --git a/server/express/src/routes/movies.ts b/server/express/src/routes/movies.ts index ecaac35..e615713 100644 --- a/server/express/src/routes/movies.ts +++ b/server/express/src/routes/movies.ts @@ -1,5 +1,18 @@ /** * 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'; @@ -16,4 +29,68 @@ const router = express.Router(); */ 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/tests/controllers/movieController.test.ts b/server/express/tests/controllers/movieController.test.ts new file mode 100644 index 0000000..086ddc0 --- /dev/null +++ b/server/express/tests/controllers/movieController.test.ts @@ -0,0 +1,645 @@ +/** + * 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 mockValidateRequiredFields = jest.fn(); + +jest.mock('../../src/utils/errorHandler', () => ({ + createSuccessResponse: mockCreateSuccessResponse, + 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, errorResponse: any) { + expect(mockStatus).toHaveBeenCalledWith(statusCode); + expect(mockJson).toHaveBeenCalledWith(errorResponse); +} + +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(`Failed to retrieve movies: ${errorMessage}`); + }); + + it('should handle unknown errors', async () => { + mockToArray.mockRejectedValue('String error'); + + await expect( + getAllMovies(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow('Failed to retrieve movies: Unknown error'); + }); + + 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 () => { + const INVALID_OBJECT_ID_ERROR = { + success: false, + error: { + message: 'Invalid movie ID format', + code: 'INVALID_OBJECT_ID' + }, + timestamp: expect.any(String) + }; + + mockRequest = createMockRequest({ params: { id: INVALID_MOVIE_ID } }); + + await getMovieById(mockRequest as Request, mockResponse as Response); + + expectErrorResponse(mockStatus, mockJson, 400, INVALID_OBJECT_ID_ERROR); + }); + + it('should return 404 when movie not found', async () => { + const MOVIE_NOT_FOUND_ERROR = { + success: false, + error: { + message: 'Movie not found', + code: 'MOVIE_NOT_FOUND' + }, + timestamp: expect.any(String) + }; + + 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_ERROR); + }); + + 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(`Failed to retrieve movie: ${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('Failed to create movie: 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); + + expect(mockStatus).toHaveBeenCalledWith(400); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + error: { + message: 'Request body must be a non-empty array of movie objects', + code: 'INVALID_INPUT' + }, + timestamp: expect.any(String) + }); + }); + + 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); + + expect(mockStatus).toHaveBeenCalledWith(400); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + error: { + message: 'No update data provided', + code: 'NO_UPDATE_DATA' + }, + timestamp: expect.any(String) + }); + }); + + 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); + + expect(mockStatus).toHaveBeenCalledWith(404); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + error: { + message: 'Movie not found', + code: 'MOVIE_NOT_FOUND' + }, + timestamp: expect.any(String) + }); + }); + + 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(`Failed to delete movie: ${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); + + expect(mockStatus).toHaveBeenCalledWith(400); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + error: { + message: 'Both filter and update objects are required', + code: 'MISSING_REQUIRED_FIELDS' + }, + timestamp: expect.any(String) + }); + }); + + it('should return 400 when update is empty', async () => { + mockRequest.body = { filter: { year: 2023 }, update: {} }; + + await updateMoviesBatch(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(400); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + error: { + message: 'Update object cannot be empty', + code: 'EMPTY_UPDATE' + }, + timestamp: expect.any(String) + }); + }); + }); + + 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); + + expect(mockStatus).toHaveBeenCalledWith(400); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + error: { + message: 'Filter object is required and cannot be empty. This prevents accidental deletion of all documents.', + code: 'MISSING_FILTER' + }, + timestamp: expect.any(String) + }); + }); + + 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); + + expect(mockStatus).toHaveBeenCalledWith(404); + expect(mockJson).toHaveBeenCalledWith({ + success: false, + error: { + message: 'Movie not found', + code: 'MOVIE_NOT_FOUND' + }, + timestamp: expect.any(String) + }); + }); + + 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(`Failed to find and delete movie: ${errorMessage}`); + }); + }); +}); + diff --git a/server/express/tests/setup.ts b/server/express/tests/setup.ts new file mode 100644 index 0000000..1121c13 --- /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(), +}; \ No newline at end of file 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", From 24818145b2d46840511f0ef3a868cf3f9c0f53f9 Mon Sep 17 00:00:00 2001 From: Jordan Smith Date: Tue, 14 Oct 2025 15:33:09 -0400 Subject: [PATCH 2/3] Cory feedback --- server/express/src/config/database.ts | 2 +- .../src/controllers/movieController.ts | 489 ++++++++---------- server/express/src/types/index.ts | 31 +- .../tests/controllers/movieController.test.ts | 136 ++--- 4 files changed, 276 insertions(+), 382 deletions(-) diff --git a/server/express/src/config/database.ts b/server/express/src/config/database.ts index 4eaab97..5161e2e 100644 --- a/server/express/src/config/database.ts +++ b/server/express/src/config/database.ts @@ -130,6 +130,6 @@ async function verifyMoviesCollection(db: Db): Promise { ); console.log('Text search index created for movies collection'); } catch (error) { - console.log('Could not create text search index.'); + 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 c8069af..c549565 100644 --- a/server/express/src/controllers/movieController.ts +++ b/server/express/src/controllers/movieController.ts @@ -17,13 +17,15 @@ */ import { Request, Response } from 'express'; -import { ObjectId } from 'mongodb'; +import { ObjectId, Sort } from 'mongodb'; import { getCollection } from '../config/database'; -import { createSuccessResponse, validateRequiredFields } from '../utils/errorHandler'; +import { createErrorResponse, createSuccessResponse, validateRequiredFields } from '../utils/errorHandler'; import { Movie, CreateMovieRequest, - UpdateMovieRequest + UpdateMovieRequest, + RawSearchQuery, + MovieFilter } from '../types'; /** @@ -57,11 +59,11 @@ export async function getAllMovies(req: Request, res: Response): Promise { skip = '0', sortBy = 'title', sortOrder = 'asc' - } = req.query as { [key: string]: string }; + }: RawSearchQuery = req.query; // Build MongoDB query filter // This demonstrates how to construct complex queries with multiple conditions - const filter: any = {}; + const filter: MovieFilter = {}; // Text search by using MongoDB's text index // This requires the text index we created in the database verification @@ -91,30 +93,35 @@ export async function getAllMovies(req: Request, res: Response): Promise { } } - // Parse pagination parameters - const limitNum = Math.min(parseInt(limit), 100); // Cap at 100 for performance - const skipNum = parseInt(skip); +// 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: any = {}; - sort[sortBy] = sortOrder === 'desc' ? -1 : 1; - - try { - // 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`)); - - } catch (error) { - throw error; - } + 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`)); } /** @@ -128,40 +135,31 @@ export async function getMovieById(req: Request, res: Response): Promise { // Validate ObjectId format if (!ObjectId.isValid(id)) { - res.status(400).json({ - success: false, - error: { - message: 'Invalid movie ID format', - code: 'INVALID_OBJECT_ID' - }, - timestamp: new Date().toISOString() - }); + res.status(400).json( + createErrorResponse( + 'Invalid movie ID format', + 'INVALID_OBJECT_ID' + ) + ); return; } const moviesCollection = getCollection('movies'); - try { - // Use findOne() to get a single document by _id - const movie = await moviesCollection.findOne({ _id: new ObjectId(id) }); - - if (!movie) { - res.status(404).json({ - success: false, - error: { - message: 'Movie not found', - code: 'MOVIE_NOT_FOUND' - }, - timestamp: new Date().toISOString() - }); - return; - } - - res.json(createSuccessResponse(movie, 'Movie retrieved successfully')); + // Use findOne() to get a single document by _id + const movie = await moviesCollection.findOne({ _id: new ObjectId(id) }); - } catch (error) { - throw new Error(`Failed to retrieve movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + if (!movie) { + res.status(404).json( + createErrorResponse( + 'Movie not found', + 'MOVIE_NOT_FOUND' + ) + ); + return; } + + res.json(createSuccessResponse(movie, 'Movie retrieved successfully')); } /** @@ -179,34 +177,29 @@ export async function createMovie(req: Request, res: Response): Promise { const moviesCollection = getCollection('movies'); - try { - // Prepare the document for insertion - // Here you can add metadata or other fields that might be necessary - const movieDocument: Partial = { - ...movieData - }; - - // Use insertOne() to create a single document - // This operation returns information about the insertion including the new _id - const result = await moviesCollection.insertOne(movieDocument); + // Prepare the document for insertion + // Here you can add metadata or other fields that might be necessary + const movieDocument: Partial = { + ...movieData + }; - if (!result.acknowledged) { - throw new Error('Movie insertion was not acknowledged by the database'); - } + // Use insertOne() to create a single document + // This operation returns information about the insertion including the new _id + const result = await moviesCollection.insertOne(movieDocument); - // Retrieve the created document to return complete data - const createdMovie = await moviesCollection.findOne({ _id: result.insertedId }); + if (!result.acknowledged) { + throw new Error('Movie insertion was not acknowledged by the database'); + } - res.status(201).json( - createSuccessResponse( - createdMovie, - `Movie '${movieData.title}' created successfully` - ) - ); + // Retrieve the created document to return complete data + const createdMovie = await moviesCollection.findOne({ _id: result.insertedId }); - } catch (error) { - throw new Error(`Failed to create movie: ${error instanceof Error ? error.message : 'Unknown error'}`); - } + res.status(201).json( + createSuccessResponse( + createdMovie, + `Movie '${movieData.title}' created successfully` + ) + ); } /** @@ -220,14 +213,12 @@ export async function createMoviesBatch(req: Request, res: Response): Promise { // Validate ObjectId format if (!ObjectId.isValid(id)) { - res.status(400).json({ - success: false, - error: { - message: 'Invalid movie ID format', - code: 'INVALID_OBJECT_ID' - }, - timestamp: new Date().toISOString() - }); + 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({ - success: false, - error: { - message: 'No update data provided', - code: 'NO_UPDATE_DATA' - }, - timestamp: new Date().toISOString() - }); + res.status(400).json( + createErrorResponse( + 'No update data provided', + 'NO_UPDATE_DATA' + ) + ); return; } const moviesCollection = getCollection('movies'); - try { - // 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({ - success: false, - error: { - message: 'Movie not found', - code: 'MOVIE_NOT_FOUND' - }, - timestamp: new Date().toISOString() - }); - 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).` + // 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' ) ); - - } catch (error) { - throw new Error(`Failed to update movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + 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).` + ) + ); } /** @@ -349,52 +324,43 @@ export async function updateMoviesBatch(req: Request, res: Response): Promise { // Validate ObjectId format if (!ObjectId.isValid(id)) { - res.status(400).json({ - success: false, - error: { - message: 'Invalid movie ID format', - code: 'INVALID_OBJECT_ID' - }, - timestamp: new Date().toISOString() - }); + res.status(400).json( + createErrorResponse( + 'Invalid movie ID format', + 'INVALID_OBJECT_ID' + ) + ); return; } const moviesCollection = getCollection('movies'); - try { - // Use deleteOne() to remove a single document - const result = await moviesCollection.deleteOne({ _id: new ObjectId(id) }); - - if (result.deletedCount === 0) { - res.status(404).json({ - success: false, - error: { - message: 'Movie not found', - code: 'MOVIE_NOT_FOUND' - }, - timestamp: new Date().toISOString() - }); - return; - } + // Use deleteOne() to remove a single document + const result = await moviesCollection.deleteOne({ _id: new ObjectId(id) }); - res.json( - createSuccessResponse( - { deletedCount: result.deletedCount }, - 'Movie deleted successfully' + if (result.deletedCount === 0) { + res.status(404).json( + createErrorResponse( + 'Movie not found', + 'MOVIE_NOT_FOUND' ) ); - - } catch (error) { - throw new Error(`Failed to delete movie: ${error instanceof Error ? error.message : 'Unknown error'}`); + return; } + + res.json( + createSuccessResponse( + { deletedCount: result.deletedCount }, + 'Movie deleted successfully' + ) + ); } /** @@ -460,34 +417,27 @@ export async function deleteMoviesBatch(req: Request, res: Response): Promise = { success: true; diff --git a/server/express/tests/controllers/movieController.test.ts b/server/express/tests/controllers/movieController.test.ts index 086ddc0..9d8ebe5 100644 --- a/server/express/tests/controllers/movieController.test.ts +++ b/server/express/tests/controllers/movieController.test.ts @@ -81,10 +81,22 @@ const mockCreateSuccessResponse = jest.fn((data: any, message: string) => ({ 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 })); @@ -128,9 +140,19 @@ function expectSuccessResponse(mockCreateSuccessResponse: jest.Mock, data: any, expect(mockCreateSuccessResponse).toHaveBeenCalledWith(data, message); } -function expectErrorResponse(mockStatus: jest.Mock, mockJson: jest.Mock, statusCode: number, errorResponse: any) { +function expectErrorResponse(mockStatus: jest.Mock, mockJson: jest.Mock, statusCode: number, errorMessage: string, errorCode: string) { expect(mockStatus).toHaveBeenCalledWith(statusCode); - expect(mockJson).toHaveBeenCalledWith(errorResponse); + 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', () => { @@ -183,15 +205,7 @@ describe('Movie Controller Tests', () => { await expect( getAllMovies(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow(`Failed to retrieve movies: ${errorMessage}`); - }); - - it('should handle unknown errors', async () => { - mockToArray.mockRejectedValue('String error'); - - await expect( - getAllMovies(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow('Failed to retrieve movies: Unknown error'); + ).rejects.toThrow(errorMessage); }); it('should handle query parameters for filtering', async () => { @@ -231,38 +245,20 @@ describe('Movie Controller Tests', () => { }); it('should return 400 for invalid ObjectId format', async () => { - const INVALID_OBJECT_ID_ERROR = { - success: false, - error: { - message: 'Invalid movie ID format', - code: 'INVALID_OBJECT_ID' - }, - timestamp: expect.any(String) - }; - mockRequest = createMockRequest({ params: { id: INVALID_MOVIE_ID } }); await getMovieById(mockRequest as Request, mockResponse as Response); - expectErrorResponse(mockStatus, mockJson, 400, INVALID_OBJECT_ID_ERROR); + expectErrorResponse(mockStatus, mockJson, 400, 'Invalid movie ID format', 'INVALID_OBJECT_ID'); }); it('should return 404 when movie not found', async () => { - const MOVIE_NOT_FOUND_ERROR = { - success: false, - error: { - message: 'Movie not found', - code: 'MOVIE_NOT_FOUND' - }, - timestamp: expect.any(String) - }; - 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_ERROR); + expectErrorResponse(mockStatus, mockJson, 404, 'Movie not found', 'MOVIE_NOT_FOUND'); }); it('should handle database errors', async () => { @@ -272,7 +268,7 @@ describe('Movie Controller Tests', () => { await expect( getMovieById(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow(`Failed to retrieve movie: ${errorMessage}`); + ).rejects.toThrow(errorMessage); }); }); @@ -320,7 +316,7 @@ describe('Movie Controller Tests', () => { await expect( createMovie(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow('Failed to create movie: Movie insertion was not acknowledged by the database'); + ).rejects.toThrow('Movie insertion was not acknowledged by the database'); }); }); @@ -358,15 +354,7 @@ describe('Movie Controller Tests', () => { await createMoviesBatch(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(400); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - error: { - message: 'Request body must be a non-empty array of movie objects', - code: 'INVALID_INPUT' - }, - timestamp: expect.any(String) - }); + 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 () => { @@ -418,15 +406,7 @@ describe('Movie Controller Tests', () => { await updateMovie(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(400); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - error: { - message: 'No update data provided', - code: 'NO_UPDATE_DATA' - }, - timestamp: expect.any(String) - }); + expectErrorResponse(mockStatus, mockJson, 400, 'No update data provided', 'NO_UPDATE_DATA'); }); it('should return 404 when movie not found', async () => { @@ -471,15 +451,7 @@ describe('Movie Controller Tests', () => { await deleteMovie(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(404); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - error: { - message: 'Movie not found', - code: 'MOVIE_NOT_FOUND' - }, - timestamp: expect.any(String) - }); + expectErrorResponse(mockStatus, mockJson, 404, 'Movie not found', 'MOVIE_NOT_FOUND'); }); it('should handle database errors', async () => { @@ -489,7 +461,7 @@ describe('Movie Controller Tests', () => { await expect( deleteMovie(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow(`Failed to delete movie: ${errorMessage}`); + ).rejects.toThrow(errorMessage); }); }); @@ -519,15 +491,7 @@ describe('Movie Controller Tests', () => { await updateMoviesBatch(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(400); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - error: { - message: 'Both filter and update objects are required', - code: 'MISSING_REQUIRED_FIELDS' - }, - timestamp: expect.any(String) - }); + expectErrorResponse(mockStatus, mockJson, 400, 'Both filter and update objects are required', 'MISSING_REQUIRED_FIELDS'); }); it('should return 400 when update is empty', async () => { @@ -535,15 +499,7 @@ describe('Movie Controller Tests', () => { await updateMoviesBatch(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(400); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - error: { - message: 'Update object cannot be empty', - code: 'EMPTY_UPDATE' - }, - timestamp: expect.any(String) - }); + expectErrorResponse(mockStatus, mockJson, 400, 'Update object cannot be empty', 'EMPTY_UPDATE'); }); }); @@ -569,15 +525,7 @@ describe('Movie Controller Tests', () => { await deleteMoviesBatch(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(400); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - error: { - message: 'Filter object is required and cannot be empty. This prevents accidental deletion of all documents.', - code: 'MISSING_FILTER' - }, - timestamp: expect.any(String) - }); + 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 () => { @@ -620,15 +568,7 @@ describe('Movie Controller Tests', () => { await findAndDeleteMovie(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(404); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - error: { - message: 'Movie not found', - code: 'MOVIE_NOT_FOUND' - }, - timestamp: expect.any(String) - }); + expectErrorResponse(mockStatus, mockJson, 404, 'Movie not found', 'MOVIE_NOT_FOUND'); }); it('should handle database errors', async () => { @@ -638,7 +578,7 @@ describe('Movie Controller Tests', () => { await expect( findAndDeleteMovie(mockRequest as Request, mockResponse as Response) - ).rejects.toThrow(`Failed to find and delete movie: ${errorMessage}`); + ).rejects.toThrow(errorMessage); }); }); }); From af81541632dee1c15737e544c67e7ae49482c5e2 Mon Sep 17 00:00:00 2001 From: Jordan Smith Date: Thu, 16 Oct 2025 10:46:32 -0400 Subject: [PATCH 3/3] Bailey feedback --- server/express/src/app.ts | 78 ++-- server/express/src/config/database.ts | 72 +-- .../src/controllers/movieController.ts | 346 +++++++-------- server/express/src/routes/movies.ts | 49 ++- server/express/src/types/index.ts | 16 +- server/express/src/utils/errorHandler.ts | 112 ++--- .../tests/controllers/movieController.test.ts | 414 +++++++++++------- server/express/tests/setup.ts | 10 +- 8 files changed, 607 insertions(+), 490 deletions(-) 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 5161e2e..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,26 +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 + { plot: "text", title: "text", fullplot: "text" }, + { + name: "text_search_index", + background: true, } ); - console.log('Text search index created for movies collection'); + console.log("Text search index created for movies collection"); } catch (error) { - console.error('Could not create text search index:', 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 c549565..d1ef1f5 100644 --- a/server/express/src/controllers/movieController.ts +++ b/server/express/src/controllers/movieController.ts @@ -1,9 +1,9 @@ /** * 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 @@ -16,24 +16,28 @@ * - findOneAndDelete() - Find and delete a movie in one operation */ -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, +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'; + 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 @@ -46,8 +50,8 @@ import { * - sortOrder: Sort direction - asc or desc (default: asc) */ export async function getAllMovies(req: Request, res: Response): Promise { - const moviesCollection = getCollection('movies'); - + const moviesCollection = getCollection("movies"); + // Extract and validate query parameters const { q, @@ -55,10 +59,10 @@ export async function getAllMovies(req: Request, res: Response): Promise { year, minRating, maxRating, - limit = '20', - skip = '0', - sortBy = 'title', - sortOrder = 'asc' + limit = "20", + skip = "0", + sortBy = "title", + sortOrder = "asc", }: RawSearchQuery = req.query; // Build MongoDB query filter @@ -73,7 +77,7 @@ export async function getAllMovies(req: Request, res: Response): Promise { // Genre filtering if (genre) { - filter.genres = { $regex: new RegExp(genre, 'i') }; + filter.genres = { $regex: new RegExp(genre, "i") }; } // Year filtering @@ -84,34 +88,34 @@ export async function getAllMovies(req: Request, res: Response): Promise { // Rating range filtering // Demonstrates nested field queries (imdb.rating) if (minRating || maxRating) { - filter['imdb.rating'] = {}; + filter["imdb.rating"] = {}; if (minRating) { - filter['imdb.rating'].$gte = parseFloat(minRating); + filter["imdb.rating"].$gte = parseFloat(minRating); } if (maxRating) { - filter['imdb.rating'].$lte = parseFloat(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 -); + // 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 + [sortBy]: sortOrder === "desc" ? -1 : 1, }; - + // Execute the find operation with all options const movies = await moviesCollection .find(filter) @@ -126,45 +130,41 @@ const skipNum = Math.max( /** * 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' - ) - ); + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); return; } - const moviesCollection = getCollection('movies'); + 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' - ) - ); + res + .status(404) + .json(createErrorResponse("Movie not found", "MOVIE_NOT_FOUND")); return; } - res.json(createSuccessResponse(movie, 'Movie retrieved successfully')); + res.json(createSuccessResponse(movie, "Movie retrieved successfully")); } /** * POST /api/movies - * + * * Creates a single new movie document. * Demonstrates the insertOne() operation. */ @@ -173,78 +173,87 @@ export async function createMovie(req: Request, res: Response): Promise { // Validate required fields // The title field is the minimum requirement for a movie - validateRequiredFields(movieData, ['title']); + validateRequiredFields(movieData, ["title"]); - const moviesCollection = getCollection('movies'); - - // Prepare the document for insertion - // Here you can add metadata or other fields that might be necessary - const movieDocument: Partial = { - ...movieData - }; + 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(movieDocument); + const result = await moviesCollection.insertOne(movieData); if (!result.acknowledged) { - throw new Error('Movie insertion was not acknowledged by the database'); + 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 }); + const createdMovie = await moviesCollection.findOne({ + _id: result.insertedId, + }); - res.status(201).json( - createSuccessResponse( - createdMovie, - `Movie '${movieData.title}' created successfully` - ) - ); + 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 { +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' - ) - ); + 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']); + validateRequiredFields(movie, ["title"]); } catch (error) { - throw new Error(`Movie at index ${index}: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Movie at index ${index}: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); } }); - const moviesCollection = getCollection('movies'); + 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'); + throw new Error( + "Batch movie insertion was not acknowledged by the database" + ); } res.status(201).json( createSuccessResponse( { insertedCount: result.insertedCount, - insertedIds: result.insertedIds + insertedIds: result.insertedIds, }, `Successfully created ${result.insertedCount} movies` ) @@ -253,7 +262,7 @@ export async function createMoviesBatch(req: Request, res: Response): Promise { // Validate ObjectId format if (!ObjectId.isValid(id)) { - res.status(400).json( - createErrorResponse( - 'Invalid movie ID format', - 'INVALID_OBJECT_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' - ) - ); + res + .status(400) + .json(createErrorResponse("No update data provided", "NO_UPDATE_DATA")); return; } - const moviesCollection = getCollection('movies'); + const moviesCollection = getCollection("movies"); // Use updateOne() to update a single document // $set operator replaces the value of fields with specified values @@ -293,17 +298,16 @@ export async function updateMovie(req: Request, res: Response): Promise { ); if (result.matchedCount === 0) { - res.status(404).json( - createErrorResponse( - 'Movie not found', - 'MOVIE_NOT_FOUND' - ) - ); + 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) }); + const updatedMovie = await moviesCollection.findOne({ + _id: new ObjectId(id), + }); res.json( createSuccessResponse( @@ -315,48 +319,49 @@ export async function updateMovie(req: Request, res: Response): Promise { /** * PATCH /api/movies - * + * * Updates multiple movies based on a filter. * Demonstrates the updateMany() operation. */ -export async function updateMoviesBatch(req: Request, res: Response): Promise { +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' - ) - ); + 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' - ) - ); + res + .status(400) + .json( + createErrorResponse("Update object cannot be empty", "EMPTY_UPDATE") + ); return; } - const moviesCollection = getCollection('movies'); + 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 } - ); + const result = await moviesCollection.updateMany(filter, { $set: update }); res.json( createSuccessResponse( { matchedCount: result.matchedCount, - modifiedCount: result.modifiedCount + modifiedCount: result.modifiedCount, }, `Update operation completed. Matched ${result.matchedCount} documents, modified ${result.modifiedCount} documents.` ) @@ -365,7 +370,7 @@ export async function updateMoviesBatch(req: Request, res: Response): Promise { // Validate ObjectId format if (!ObjectId.isValid(id)) { - res.status(400).json( - createErrorResponse( - 'Invalid movie ID format', - 'INVALID_OBJECT_ID' - ) - ); + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); return; } - const moviesCollection = getCollection('movies'); + 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' - ) - ); + res + .status(404) + .json(createErrorResponse("Movie not found", "MOVIE_NOT_FOUND")); return; } res.json( createSuccessResponse( { deletedCount: result.deletedCount }, - 'Movie deleted successfully' + "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 { +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' - ) - ); + 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'); + 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 @@ -442,47 +448,43 @@ export async function deleteMoviesBatch(req: Request, res: Response): Promise { +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' - ) - ); + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); return; } - const moviesCollection = getCollection('movies'); + 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) } - ); + const deletedMovie = await moviesCollection.findOneAndDelete({ + _id: new ObjectId(id), + }); if (!deletedMovie) { - res.status(404).json( - createErrorResponse( - 'Movie not found', - 'MOVIE_NOT_FOUND' - ) - ); + res + .status(404) + .json(createErrorResponse("Movie not found", "MOVIE_NOT_FOUND")); return; } res.json( - createSuccessResponse( - deletedMovie, - 'Movie found and deleted successfully' - ) + createSuccessResponse(deletedMovie, "Movie found and deleted successfully") ); -} \ No newline at end of file +} diff --git a/server/express/src/routes/movies.ts b/server/express/src/routes/movies.ts index e615713..c8b214c 100644 --- a/server/express/src/routes/movies.ts +++ b/server/express/src/routes/movies.ts @@ -1,8 +1,8 @@ /** * Movies API Routes - * + * * This module defines the routing endpoints for movie operations. - * + * * Implemented operations: * - insertOne() - Create a single movie * - insertMany() - Create multiple movies @@ -15,82 +15,85 @@ * - 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)); /** * GET /api/movies/:id - * + * * Retrieves a single movie by its ObjectId. * Demonstrates the findOne() operation. */ -router.get('/:id', asyncHandler(movieController.getMovieById)); +router.get("/:id", asyncHandler(movieController.getMovieById)); /** * POST /api/movies - * + * * Creates a single new movie document. * Demonstrates the insertOne() operation. */ -router.post('/', asyncHandler(movieController.createMovie)); +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)); +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)); +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)); +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)); +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)); +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)); +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 9d44ed2..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] }; }; @@ -154,7 +154,7 @@ export type MovieFilter = { $text?: { $search: string }; genres?: { $regex: RegExp }; year?: number; - 'imdb.rating'?: { + "imdb.rating"?: { $gte?: number; $lte?: number; }; @@ -171,7 +171,7 @@ export type SuccessResponse = { total: number; pages: number; }; -} +}; export type ErrorResponse = { success: false; @@ -182,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 index 9d8ebe5..3309932 100644 --- a/server/express/tests/controllers/movieController.test.ts +++ b/server/express/tests/controllers/movieController.test.ts @@ -1,41 +1,40 @@ /** * 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'; +import { Request, Response } from "express"; +import { ObjectId } from "mongodb"; // Test Data Constants -const TEST_MOVIE_ID = '507f1f77bcf86cd799439011'; -const INVALID_MOVIE_ID = 'invalid-id'; +const TEST_MOVIE_ID = "507f1f77bcf86cd799439011"; +const INVALID_MOVIE_ID = "invalid-id"; const SAMPLE_MOVIE = { _id: TEST_MOVIE_ID, - title: 'Test Movie', + title: "Test Movie", year: 2024, - plot: 'A test movie', - genres: ['Action'] + plot: "A test movie", + genres: ["Action"], }; const SAMPLE_MOVIES = [ { _id: TEST_MOVIE_ID, - title: 'Test Movie 1', + title: "Test Movie 1", year: 2024, - plot: 'A test movie', - genres: ['Action'] + plot: "A test movie", + genres: ["Action"], }, { - _id: TEST_MOVIE_ID + '-b', - title: 'Test Movie 2', + _id: TEST_MOVIE_ID + "-b", + title: "Test Movie 2", year: 2024, - plot: 'Another test movie', - genres: ['Comedy'] - } - + plot: "Another test movie", + genres: ["Comedy"], + }, ]; // Create mock collection methods @@ -56,7 +55,7 @@ const mockGetCollection = jest.fn(() => ({ toArray: mockToArray, limit: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(), - sort: jest.fn().mockReturnThis() + sort: jest.fn().mockReturnThis(), }), findOne: mockFindOne, insertOne: mockInsertOne, @@ -65,12 +64,12 @@ const mockGetCollection = jest.fn(() => ({ updateMany: mockUpdateMany, deleteOne: mockDeleteOne, deleteMany: mockDeleteMany, - findOneAndDelete: mockFindOneAndDelete + findOneAndDelete: mockFindOneAndDelete, })); // Mock the database module -jest.mock('../../src/config/database', () => ({ - getCollection: mockGetCollection +jest.mock("../../src/config/database", () => ({ + getCollection: mockGetCollection, })); // Mock the error handler utilities @@ -78,30 +77,32 @@ const mockCreateSuccessResponse = jest.fn((data: any, message: string) => ({ success: true, message, data, - timestamp: '2024-01-01T00:00:00.000Z' + timestamp: "2024-01-01T00:00:00.000Z", })); -const mockCreateErrorResponse = jest.fn((message: string, code?: string, details?: any) => ({ - success: false, - message, - error: { +const mockCreateErrorResponse = jest.fn( + (message: string, code?: string, details?: any) => ({ + success: false, message, - code, - details - }, - timestamp: '2024-01-01T00:00:00.000Z' -})); + error: { + message, + code, + details, + }, + timestamp: "2024-01-01T00:00:00.000Z", + }) +); const mockValidateRequiredFields = jest.fn(); -jest.mock('../../src/utils/errorHandler', () => ({ +jest.mock("../../src/utils/errorHandler", () => ({ createSuccessResponse: mockCreateSuccessResponse, createErrorResponse: mockCreateErrorResponse, - validateRequiredFields: mockValidateRequiredFields + validateRequiredFields: mockValidateRequiredFields, })); // Import controller methods after mocks -import { +import { getAllMovies, getMovieById, createMovie, @@ -110,8 +111,8 @@ import { updateMoviesBatch, deleteMovie, deleteMoviesBatch, - findAndDeleteMovie -} from '../../src/controllers/movieController'; + findAndDeleteMovie, +} from "../../src/controllers/movieController"; // Helper Functions function createMockRequest(overrides: Partial = {}): Partial { @@ -119,28 +120,42 @@ function createMockRequest(overrides: Partial = {}): Partial { query: {}, params: {}, body: {}, - ...overrides + ...overrides, }; } -function createMockResponse(): { mockJson: jest.Mock; mockStatus: jest.Mock; mockResponse: Partial } { +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() + setHeader: jest.fn(), }; return { mockJson, mockStatus, mockResponse }; } -function expectSuccessResponse(mockCreateSuccessResponse: jest.Mock, data: any, message: string) { +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) { +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({ @@ -149,13 +164,13 @@ function expectErrorResponse(mockStatus: jest.Mock, mockJson: jest.Mock, statusC error: { message: errorMessage, code: errorCode, - details: undefined + details: undefined, }, - timestamp: '2024-01-01T00:00:00.000Z' + timestamp: "2024-01-01T00:00:00.000Z", }); } -describe('Movie Controller Tests', () => { +describe("Movie Controller Tests", () => { let mockRequest: Partial; let mockResponse: Partial; let mockJson: jest.Mock; @@ -164,43 +179,47 @@ describe('Movie Controller Tests', () => { 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 () => { + 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(mockGetCollection).toHaveBeenCalledWith("movies"); expect(mockFind).toHaveBeenCalledWith({}); - expectSuccessResponse(mockCreateSuccessResponse, SAMPLE_MOVIES, 'Found 2 movies'); + expectSuccessResponse( + mockCreateSuccessResponse, + SAMPLE_MOVIES, + "Found 2 movies" + ); expect(mockJson).toHaveBeenCalledWith({ success: true, - message: 'Found 2 movies', + message: "Found 2 movies", data: SAMPLE_MOVIES, - timestamp: '2024-01-01T00:00:00.000Z' + timestamp: "2024-01-01T00:00:00.000Z", }); }); - it('should handle empty results', async () => { + it("should handle empty results", async () => { mockToArray.mockResolvedValue([]); await getAllMovies(mockRequest as Request, mockResponse as Response); - expectSuccessResponse(mockCreateSuccessResponse, [], 'Found 0 movies'); + expectSuccessResponse(mockCreateSuccessResponse, [], "Found 0 movies"); }); - it('should handle database errors', async () => { - const errorMessage = 'Database connection failed'; + it("should handle database errors", async () => { + const errorMessage = "Database connection failed"; mockToArray.mockRejectedValue(new Error(errorMessage)); await expect( @@ -208,62 +227,83 @@ describe('Movie Controller Tests', () => { ).rejects.toThrow(errorMessage); }); - it('should handle query parameters for filtering', async () => { - const testMovies = [{ _id: TEST_MOVIE_ID, title: 'Action Movie' }]; + 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' + 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') }, + genres: { $regex: new RegExp("Action", "i") }, year: 2024, - 'imdb.rating': { $gte: 7.0 } + "imdb.rating": { $gte: 7.0 }, }); - expect(mockCreateSuccessResponse).toHaveBeenCalledWith(testMovies, 'Found 1 movies'); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + testMovies, + "Found 1 movies" + ); }); }); - describe('getMovieById', () => { - it('should successfully retrieve a movie by valid ID', async () => { + 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(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 () => { + 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'); + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Invalid movie ID format", + "INVALID_OBJECT_ID" + ); }); - it('should return 404 when movie not found', async () => { + 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'); + expectErrorResponse( + mockStatus, + mockJson, + 404, + "Movie not found", + "MOVIE_NOT_FOUND" + ); }); - it('should handle database errors', async () => { + it("should handle database errors", async () => { mockRequest = createMockRequest({ params: { id: TEST_MOVIE_ID } }); - const errorMessage = 'Database error'; + const errorMessage = "Database error"; mockFindOne.mockRejectedValue(new Error(errorMessage)); await expect( @@ -272,9 +312,9 @@ describe('Movie Controller Tests', () => { }); }); - describe('createMovie', () => { - it('should successfully create a movie', async () => { - const movieData = { title: 'New Movie', year: 2024 }; + 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 }; @@ -284,9 +324,13 @@ describe('Movie Controller Tests', () => { await createMovie(mockRequest as Request, mockResponse as Response); - expect(mockValidateRequiredFields).toHaveBeenCalledWith(movieData, ['title']); + expect(mockValidateRequiredFields).toHaveBeenCalledWith(movieData, [ + "title", + ]); expect(mockInsertOne).toHaveBeenCalledWith(movieData); - expect(mockFindOne).toHaveBeenCalledWith({ _id: insertResult.insertedId }); + expect(mockFindOne).toHaveBeenCalledWith({ + _id: insertResult.insertedId, + }); expect(mockStatus).toHaveBeenCalledWith(201); expect(mockCreateSuccessResponse).toHaveBeenCalledWith( createdMovie, @@ -294,42 +338,41 @@ describe('Movie Controller Tests', () => { ); }); - it('should handle validation errors', async () => { - const movieData = { /* missing title */ }; + it("should handle validation errors", async () => { + const movieData = { + /* missing title */ + }; mockRequest.body = movieData; - - const error = new Error('Missing required fields: title'); + + 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'); + ).rejects.toThrow("Missing required fields: title"); }); - it('should handle insert acknowledgment failure', async () => { - const movieData = { title: 'Test Movie' }; + 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'); + ).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' } - ]; + 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()] + insertedIds: [new ObjectId(), new ObjectId()], }; mockRequest.body = moviesData; @@ -343,21 +386,27 @@ describe('Movie Controller Tests', () => { expect(mockCreateSuccessResponse).toHaveBeenCalledWith( { insertedCount: 2, - insertedIds: insertResult.insertedIds + insertedIds: insertResult.insertedIds, }, - 'Successfully created 2 movies' + "Successfully created 2 movies" ); }); - it('should return 400 for invalid input (not an array)', async () => { - mockRequest.body = { title: 'Single Movie' }; + 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'); + 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 () => { + it("should return 400 for empty array", async () => { mockRequest.body = []; await createMoviesBatch(mockRequest as Request, mockResponse as Response); @@ -366,12 +415,11 @@ describe('Movie Controller Tests', () => { }); }); - describe('updateMovie', () => { - - it('should successfully update a movie', async () => { - const updateData = { title: 'Updated Movie' }; + 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' }; + const updatedMovie = { _id: TEST_MOVIE_ID, title: "Updated Movie" }; mockRequest.params = { id: TEST_MOVIE_ID }; mockRequest.body = updateData; @@ -384,34 +432,42 @@ describe('Movie Controller Tests', () => { { _id: new ObjectId(TEST_MOVIE_ID) }, { $set: updateData } ); - expect(mockFindOne).toHaveBeenCalledWith({ _id: new ObjectId(TEST_MOVIE_ID) }); + expect(mockFindOne).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_MOVIE_ID), + }); expect(mockCreateSuccessResponse).toHaveBeenCalledWith( updatedMovie, - 'Movie updated successfully. Modified 1 field(s).' + "Movie updated successfully. Modified 1 field(s)." ); }); - it('should return 400 for invalid ObjectId', async () => { + it("should return 400 for invalid ObjectId", async () => { mockRequest.params = { id: INVALID_MOVIE_ID }; - mockRequest.body = { title: 'Updated' }; + 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 () => { + 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'); + expectErrorResponse( + mockStatus, + mockJson, + 400, + "No update data provided", + "NO_UPDATE_DATA" + ); }); - it('should return 404 when movie not found', async () => { + it("should return 404 when movie not found", async () => { mockRequest.params = { id: TEST_MOVIE_ID }; - mockRequest.body = { title: 'Updated' }; + mockRequest.body = { title: "Updated" }; mockUpdateOne.mockResolvedValue({ matchedCount: 0, modifiedCount: 0 }); await updateMovie(mockRequest as Request, mockResponse as Response); @@ -420,9 +476,8 @@ describe('Movie Controller Tests', () => { }); }); - describe('deleteMovie', () => { - - it('should successfully delete a movie', async () => { + describe("deleteMovie", () => { + it("should successfully delete a movie", async () => { const deleteResult = { deletedCount: 1 }; mockRequest.params = { id: TEST_MOVIE_ID }; @@ -430,14 +485,16 @@ describe('Movie Controller Tests', () => { await deleteMovie(mockRequest as Request, mockResponse as Response); - expect(mockDeleteOne).toHaveBeenCalledWith({ _id: new ObjectId(TEST_MOVIE_ID) }); + expect(mockDeleteOne).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_MOVIE_ID), + }); expect(mockCreateSuccessResponse).toHaveBeenCalledWith( { deletedCount: 1 }, - 'Movie deleted successfully' + "Movie deleted successfully" ); }); - it('should return 400 for invalid ObjectId', async () => { + it("should return 400 for invalid ObjectId", async () => { mockRequest.params = { id: INVALID_MOVIE_ID }; await deleteMovie(mockRequest as Request, mockResponse as Response); @@ -445,18 +502,24 @@ describe('Movie Controller Tests', () => { expect(mockStatus).toHaveBeenCalledWith(400); }); - it('should return 404 when movie not found', async () => { + 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'); + expectErrorResponse( + mockStatus, + mockJson, + 404, + "Movie not found", + "MOVIE_NOT_FOUND" + ); }); - it('should handle database errors', async () => { + it("should handle database errors", async () => { mockRequest.params = { id: TEST_MOVIE_ID }; - const errorMessage = 'Database error'; + const errorMessage = "Database error"; mockDeleteOne.mockRejectedValue(new Error(errorMessage)); await expect( @@ -465,10 +528,10 @@ describe('Movie Controller Tests', () => { }); }); - describe('updateMoviesBatch', () => { - it('should successfully update multiple movies', async () => { + describe("updateMoviesBatch", () => { + it("should successfully update multiple movies", async () => { const filter = { year: 2023 }; - const update = { genre: 'Updated Genre' }; + const update = { genre: "Updated Genre" }; const updateResult = { matchedCount: 5, modifiedCount: 3 }; mockRequest.body = { filter, update }; @@ -480,31 +543,43 @@ describe('Movie Controller Tests', () => { expect(mockCreateSuccessResponse).toHaveBeenCalledWith( { matchedCount: 5, - modifiedCount: 3 + modifiedCount: 3, }, - 'Update operation completed. Matched 5 documents, modified 3 documents.' + "Update operation completed. Matched 5 documents, modified 3 documents." ); }); - it('should return 400 when filter is missing', async () => { - mockRequest.body = { update: { title: 'Updated' } }; + 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'); + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Both filter and update objects are required", + "MISSING_REQUIRED_FIELDS" + ); }); - it('should return 400 when update is empty', async () => { + 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'); + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Update object cannot be empty", + "EMPTY_UPDATE" + ); }); }); - describe('deleteMoviesBatch', () => { - it('should successfully delete multiple movies', async () => { + describe("deleteMoviesBatch", () => { + it("should successfully delete multiple movies", async () => { const filter = { year: { $lt: 2000 } }; const deleteResult = { deletedCount: 10 }; @@ -516,19 +591,25 @@ describe('Movie Controller Tests', () => { expect(mockDeleteMany).toHaveBeenCalledWith(filter); expect(mockCreateSuccessResponse).toHaveBeenCalledWith( { deletedCount: 10 }, - 'Delete operation completed. Removed 10 documents.' + "Delete operation completed. Removed 10 documents." ); }); - it('should return 400 when filter is missing', async () => { + 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'); + 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 () => { + it("should return 400 when filter is empty", async () => { mockRequest.body = { filter: {} }; await deleteMoviesBatch(mockRequest as Request, mockResponse as Response); @@ -537,43 +618,59 @@ describe('Movie Controller Tests', () => { }); }); - describe('findAndDeleteMovie', () => { - - it('should successfully find and delete a movie', async () => { - const deletedMovie = { _id: TEST_MOVIE_ID, title: 'Deleted Movie' }; + 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); + await findAndDeleteMovie( + mockRequest as Request, + mockResponse as Response + ); - expect(mockFindOneAndDelete).toHaveBeenCalledWith({ _id: new ObjectId(TEST_MOVIE_ID) }); + expect(mockFindOneAndDelete).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_MOVIE_ID), + }); expect(mockCreateSuccessResponse).toHaveBeenCalledWith( deletedMovie, - 'Movie found and deleted successfully' + "Movie found and deleted successfully" ); }); - it('should return 400 for invalid ObjectId', async () => { + it("should return 400 for invalid ObjectId", async () => { mockRequest.params = { id: INVALID_MOVIE_ID }; - await findAndDeleteMovie(mockRequest as Request, mockResponse as Response); + await findAndDeleteMovie( + mockRequest as Request, + mockResponse as Response + ); expect(mockStatus).toHaveBeenCalledWith(400); }); - it('should return 404 when movie not found', async () => { + 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); + await findAndDeleteMovie( + mockRequest as Request, + mockResponse as Response + ); - expectErrorResponse(mockStatus, mockJson, 404, 'Movie not found', 'MOVIE_NOT_FOUND'); + expectErrorResponse( + mockStatus, + mockJson, + 404, + "Movie not found", + "MOVIE_NOT_FOUND" + ); }); - it('should handle database errors', async () => { + it("should handle database errors", async () => { mockRequest.params = { id: TEST_MOVIE_ID }; - const errorMessage = 'Database error'; + const errorMessage = "Database error"; mockFindOneAndDelete.mockRejectedValue(new Error(errorMessage)); await expect( @@ -582,4 +679,3 @@ describe('Movie Controller Tests', () => { }); }); }); - diff --git a/server/express/tests/setup.ts b/server/express/tests/setup.ts index 1121c13..df081e6 100644 --- a/server/express/tests/setup.ts +++ b/server/express/tests/setup.ts @@ -1,14 +1,14 @@ /** * 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'; +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); @@ -20,4 +20,4 @@ global.console = { log: jest.fn(), warn: jest.fn(), error: jest.fn(), -}; \ No newline at end of file +};