diff --git a/client/app/lib/api.ts b/client/app/lib/api.ts index e8417e2..4ec0f96 100644 --- a/client/app/lib/api.ts +++ b/client/app/lib/api.ts @@ -410,7 +410,7 @@ export async function fetchMoviesWithComments( const queryParams = new URLSearchParams(); queryParams.append('limit', limit.toString()); if (movieId) { - queryParams.append('movie_id', movieId); + queryParams.append('movieId', movieId); } console.log(`Fetching comments from: ${API_BASE_URL}/api/movies/aggregations/reportingByComments?${queryParams}`); diff --git a/client/app/lib/constants.ts b/client/app/lib/constants.ts index 9eb2092..391dfec 100644 --- a/client/app/lib/constants.ts +++ b/client/app/lib/constants.ts @@ -7,6 +7,7 @@ export const APP_CONFIG = { description: 'Browse movies from the sample MFlix database', defaultMovieLimit: 20, maxMovieLimit: 100, + vectorSearchPageSize: 20, // Fixed page size for vector search results display imageFormats: ['image/avif', 'image/webp'], } as const; diff --git a/client/app/movies/page.tsx b/client/app/movies/page.tsx index 504fa1a..fdba676 100644 --- a/client/app/movies/page.tsx +++ b/client/app/movies/page.tsx @@ -239,16 +239,18 @@ export default function Movies() { let result; if (searchParams.searchType === 'vector-search') { - // Vector Search: Fetch all results and implement client-side pagination + // Vector Search: Use the limit from search params as the fetch limit const vectorSearchParams = { q: searchParams.q!, - limit: searchParams.limit || 50, // Get more results for better pagination experience + limit: searchParams.limit || 50, // This is how many results to fetch from backend }; + result = await vectorSearchMovies(vectorSearchParams); if (result.success) { const allResults = result.movies || []; - const pageSize = searchParams.limit || 20; + const pageSize = APP_CONFIG.vectorSearchPageSize; // Fixed page size for UI display + setAllVectorSearchResults(allResults); setVectorSearchPage(1); setVectorSearchPageSize(pageSize); @@ -257,10 +259,7 @@ export default function Movies() { const firstPageResults = allResults.slice(0, pageSize); setSearchResults(firstPageResults); - // Set pagination state based on total results - const totalPages = Math.ceil(allResults.length / pageSize); - setSearchHasNextPage(totalPages > 1); - setSearchHasPrevPage(false); + // Set pagination state for vector search setSearchTotalCount(allResults.length); } } else { @@ -352,15 +351,19 @@ export default function Movies() { return { paginatedResults: [], hasNext: false, hasPrev: false, totalPages: 0 }; } + const totalResults = allVectorSearchResults.length; + const totalPages = Math.ceil(totalResults / vectorSearchPageSize); + const hasNext = vectorSearchPage < totalPages; + const hasPrev = vectorSearchPage > 1; + const startIndex = (vectorSearchPage - 1) * vectorSearchPageSize; const endIndex = startIndex + vectorSearchPageSize; const paginatedResults = allVectorSearchResults.slice(startIndex, endIndex); - const totalPages = Math.ceil(allVectorSearchResults.length / vectorSearchPageSize); return { paginatedResults, - hasNext: vectorSearchPage < totalPages, - hasPrev: vectorSearchPage > 1, + hasNext, + hasPrev, totalPages }; }; @@ -653,55 +656,60 @@ export default function Movies() { /* Vector search results with client-side pagination */ (() => { const { hasNext, hasPrev, totalPages } = getVectorSearchPageData(); - return totalPages > 1 ? ( - + ); + } else { + return ( +
+ Showing {allVectorSearchResults.length} results (vector search)
- - ) : ( -
- Showing {allVectorSearchResults.length} results (vector search) -
- ); + ); + } })() ) ) : ( diff --git a/client/tsconfig.json b/client/tsconfig.json index ddba9b4..b0209d7 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { diff --git a/server/express/package.json b/server/express/package.json index 29b341c..c067a31 100644 --- a/server/express/package.json +++ b/server/express/package.json @@ -12,7 +12,10 @@ "dev": "ts-node src/app.ts", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "test:unit": "jest tests/controllers", + "test:verbose": "jest --verbose", + "test:silent": "jest --silent" }, "dependencies": { "cors": "^2.8.5", diff --git a/server/express/src/controllers/movieController.ts b/server/express/src/controllers/movieController.ts index 13021b4..14269dc 100644 --- a/server/express/src/controllers/movieController.ts +++ b/server/express/src/controllers/movieController.ts @@ -17,7 +17,7 @@ */ import { Request, Response } from "express"; -import { ObjectId, Sort } from "mongodb"; +import { ObjectId, Sort, Document } from "mongodb"; import { getCollection } from "../config/database"; import { createErrorResponse, @@ -25,11 +25,17 @@ import { validateRequiredFields, } from "../utils/errorHandler"; import { - Movie, CreateMovieRequest, UpdateMovieRequest, RawSearchQuery, MovieFilter, + VectorSearchResult, + MovieWithCommentsResult, + SearchMoviesResponse, + RawMovieSearchQuery, + SearchPhrase, + AggregationComment, + VoyageAIResponse, } from "../types"; /** @@ -516,3 +522,691 @@ export async function findAndDeleteMovie( createSuccessResponse(deletedMovie, "Movie found and deleted successfully") ); } + +/** + * GET /api/movies/search + * + * Search movies using MongoDB Search across multiple fields. + * Demonstrates MongoDB Search with compound queries. + */ +export async function searchMovies(req: Request, res: Response): Promise { + const moviesCollection = getCollection("movies"); + + const { + plot, + fullplot, + directors, + writers, + cast, + limit = "20", + skip = "0", + searchOperator = "must", + }: RawMovieSearchQuery = req.query; + + // Validate search operator + const validOperators = ["must", "should", "mustNot", "filter"]; + if (!validOperators.includes(searchOperator)) { + res + .status(400) + .json( + createErrorResponse( + `Invalid search_operator '${searchOperator}'. Must be one of: ${validOperators.join(", ")}`, + "INVALID_SEARCH_OPERATOR" + ) + ); + return; + } + + // Build search phrases array + const searchPhrases: SearchPhrase[] = []; + + if (plot) { + searchPhrases.push({ + phrase: { + query: plot, + path: "plot", + }, + }); + } + + if (fullplot) { + searchPhrases.push({ + phrase: { + query: fullplot, + path: "fullplot", + }, + }); + } + + if (directors) { + searchPhrases.push({ + text: { + query: directors, + path: "directors", + fuzzy: { maxEdits: 1, prefixLength: 5 }, + }, + }); + } + + if (writers) { + searchPhrases.push({ + text: { + query: writers, + path: "writers", + fuzzy: { maxEdits: 1, prefixLength: 5 }, + }, + }); + } + + if (cast) { + searchPhrases.push({ + text: { + query: cast, + path: "cast", + fuzzy: { maxEdits: 1, prefixLength: 5 }, + }, + }); + } + + if (searchPhrases.length === 0) { + res + .status(400) + .json( + createErrorResponse( + "At least one search parameter must be provided", + "NO_SEARCH_PARAMETERS" + ) + ); + return; + } + + // Parse pagination parameters + const limitNum = Math.min(Math.max(parseInt(limit) || 20, 1), 100); + const skipNum = Math.max(parseInt(skip) || 0, 0); + + // Build aggregation pipeline + const pipeline = [ + { + $search: { + index: "movieSearchIndex", + compound: { + [searchOperator]: searchPhrases, + }, + }, + }, + { + $facet: { + totalCount: [{ $count: "count" }], + results: [ + { $skip: skipNum }, + { $limit: limitNum }, + { + $project: { + _id: 1, + title: 1, + year: 1, + plot: 1, + fullplot: 1, + released: 1, + runtime: 1, + poster: 1, + genres: 1, + directors: 1, + writers: 1, + cast: 1, + countries: 1, + languages: 1, + rated: 1, + awards: 1, + imdb: 1, + }, + }, + ], + }, + }, + ]; + + const results = await moviesCollection.aggregate(pipeline).toArray(); + const facetResult = results[0] || {}; + const totalCount = facetResult.totalCount?.[0]?.count || 0; + const movies = facetResult.results || []; + + const response: SearchMoviesResponse = { + movies, + totalCount, + }; + + res.json( + createSuccessResponse( + response, + `Found ${totalCount} movies matching the search criteria` + ) + ); +} + +/** + * GET /api/movies/vector-search + * + * Search movies using MongoDB Vector Search for semantic similarity. + * Demonstrates vector search using embeddings to find similar plots. + */ +export async function vectorSearchMovies(req: Request, res: Response): Promise { + const { q, limit = "10" } = req.query; + + // Validate query parameter + if (!q || typeof q !== "string" || q.trim().length === 0) { + res + .status(400) + .json( + createErrorResponse( + "Search query is required", + "MISSING_QUERY_PARAMETER" + ) + ); + return; + } + + // Check if Voyage AI API key is configured + if (!process.env.VOYAGE_API_KEY || process.env.VOYAGE_API_KEY.trim().length === 0) { + res + .status(400) + .json( + createErrorResponse( + "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + "SERVICE_UNAVAILABLE" + ) + ); + return; + } + + // Validate and set limit (default: 20, min: 1, max: 50) + const limitNum = Math.min(Math.max(parseInt(limit as string) || 20, 1), 50); + + try { + // Generate embedding using Voyage AI REST API + const queryVector = await generateVoyageEmbedding(q.trim(), process.env.VOYAGE_API_KEY); + + // Get embedded movies collection for vector search + const embeddedMoviesCollection = getCollection("embedded_movies"); + + // Step 1: Build the $vectorSearch aggregation pipeline for embedded_movies + const vectorSearchPipeline = [ + { + $vectorSearch: { + index: "vector_index", + path: "plot_embedding_voyage_3_large", + queryVector: queryVector, + numCandidates: limitNum * 20, // We recommend searching 20 times higher than the limit to improve result relevance + limit: limitNum, + }, + }, + { + $project: { + _id: 1, + score: { $meta: "vectorSearchScore" }, + }, + }, + ]; + + // Execute vector search to get movie IDs and scores + const vectorResults = await embeddedMoviesCollection.aggregate(vectorSearchPipeline).toArray(); + + if (vectorResults.length === 0) { + res.json( + createSuccessResponse( + [], + `No similar movies found for query: '${q}'` + ) + ); + return; + } + + // Extract movie IDs and create score mapping + const movieIds = vectorResults.map(doc => doc._id); + const scoreMap = new Map(); + vectorResults.forEach(doc => { + scoreMap.set(doc._id.toString(), doc.score); + }); + + // Step 2: Fetch complete movie data from the movies collection + const moviesCollection = getCollection("movies"); + + // Build aggregation pipeline to safely handle year field and get complete movie data + const moviesPipeline = [ + { + $match: { + _id: { $in: movieIds } + } + }, + { + $project: { + _id: 1, + title: 1, + plot: 1, + poster: 1, + genres: 1, + directors: 1, + cast: 1, + // Safely convert year to integer, handling strings and dirty data + year: { + $cond: { + if: { + $and: [ + { $ne: ["$year", null] }, + { $eq: [{ $type: "$year" }, "int"] } + ] + }, + then: "$year", + else: null + } + } + } + } + ]; + + const movieResults = await moviesCollection.aggregate(moviesPipeline).toArray(); + + // Step 3: Combine movie data with vector search scores + const finalResults: VectorSearchResult[] = movieResults.map(movie => { + const movieIdStr = movie._id.toString(); + const score = scoreMap.get(movieIdStr) || 0; + + return { + _id: movieIdStr, + title: movie.title || '', + plot: movie.plot, + poster: movie.poster, + year: movie.year, + genres: movie.genres || [], + directors: movie.directors || [], + cast: movie.cast || [], + score: score, + }; + }); + + // Sort results by score (highest first) to maintain relevance order + finalResults.sort((a, b) => b.score - a.score); + + res.json( + createSuccessResponse( + finalResults, + `Found ${finalResults.length} similar movies for query: '${q}'` + ) + ); + } catch (error) { + console.error("Vector search error:", error); + res + .status(500) + .json( + createErrorResponse( + "Error performing vector search", + "VECTOR_SEARCH_ERROR", + error instanceof Error ? error.message : "Unknown error" + ) + ); + } +} + +/** + * GET /api/movies/aggregations/comments + * + * Aggregate movies with their most recent comments. + * Demonstrates MongoDB $lookup aggregation to join collections. + */ +export async function getMoviesWithMostRecentComments( + req: Request, + res: Response +): Promise { + const moviesCollection = getCollection("movies"); + const { limit = "10", movieId } = req.query; + + const limitNum = Math.min(Math.max(parseInt(limit as string) || 10, 1), 50); + + // Build aggregation pipeline + const pipeline: Document[] = [ + // STAGE 1: Initial filter for data quality - filters bad data in + // the collection + { + $match: { + year: { $type: "number", $gte: 1800, $lte: 2030 }, + }, + }, + ]; + + // Add movie ID filter if provided + if (movieId && typeof movieId === "string") { + if (!ObjectId.isValid(movieId)) { + res + .status(400) + .json( + createErrorResponse("Invalid movie ID format", "INVALID_OBJECT_ID") + ); + return; + } + pipeline[0].$match._id = new ObjectId(movieId); + } + + // Add remaining pipeline stages + pipeline.push( + // STAGE 2: Join with comments collection + { + $lookup: { + from: "comments", + localField: "_id", + foreignField: "movie_id", + as: "comments", + }, + }, + // STAGE 3: Filter movies with at least one comment + { + $match: { + comments: { $ne: [] }, + }, + }, + // STAGE 4: Add computed fields + { + $addFields: { + recentComments: { + $slice: [ + { + $sortArray: { + input: "$comments", + sortBy: { date: -1 }, + }, + }, + limitNum, + ], + }, + mostRecentCommentDate: { + $max: "$comments.date", + }, + }, + }, + // STAGE 5: Sort by most recent comment date + { + $sort: { mostRecentCommentDate: -1 }, + }, + // STAGE 6: Limit results + { + $limit: movieId ? 50 : 20, + }, + // STAGE 7: Shape final output + { + $project: { + title: 1, + year: 1, + genres: 1, + _id: 1, + imdbRating: "$imdb.rating", + recentComments: { + $map: { + input: "$recentComments", + as: "comment", + in: { + _id: "$$comment._id", + userName: "$$comment.name", + userEmail: "$$comment.email", + text: "$$comment.text", + date: "$$comment.date", + }, + }, + }, + totalComments: { $size: "$comments" }, + }, + } + ); + + const results = await moviesCollection.aggregate(pipeline).toArray(); + + // Convert ObjectId to string for response + const processedResults: MovieWithCommentsResult[] = results.map((result) => ({ + _id: result._id.toString(), + title: result.title, + year: result.year, + genres: result.genres, + imdbRating: result.imdbRating, + recentComments: result.recentComments.map((comment: AggregationComment) => ({ + _id: comment._id?.toString(), + userName: comment.userName, + userEmail: comment.userEmail, + text: comment.text, + date: comment.date, + })), + totalComments: result.totalComments, + })); + + // Calculate total comments across all movies + const totalComments = processedResults.reduce( + (sum, result) => sum + (result.totalComments || 0), + 0 + ); + + const message = movieId + ? `Found ${totalComments} comments from movie` + : `Found ${totalComments} comments from ${processedResults.length} movie${ + processedResults.length !== 1 ? "s" : "" + }`; + + res.json(createSuccessResponse(processedResults, message)); +} + +/** + * GET /api/movies/aggregations/years + * + * Aggregate movies by year with statistics. + * Demonstrates MongoDB $group aggregation for statistical calculations. + */ +export async function getMoviesByYearWithStats( + req: Request, + res: Response +): Promise { + const moviesCollection = getCollection("movies"); + + const pipeline = [ + // STAGE 1: Data quality filter + { + $match: { + year: { $type: "number", $gte: 1800, $lte: 2030 }, + }, + }, + // STAGE 2: Group by year and calculate statistics + { + $group: { + _id: "$year", + movieCount: { $sum: 1 }, + averageRating: { + $avg: { + $cond: [ + { + $and: [ + { $ne: ["$imdb.rating", null] }, + { $ne: ["$imdb.rating", ""] }, + { $eq: [{ $type: "$imdb.rating" }, "double"] }, + ], + }, + "$imdb.rating", + "$$REMOVE", + ], + }, + }, + highestRating: { + $max: { + $cond: [ + { + $and: [ + { $ne: ["$imdb.rating", null] }, + { $ne: ["$imdb.rating", ""] }, + { $eq: [{ $type: "$imdb.rating" }, "double"] }, + ], + }, + "$imdb.rating", + "$$REMOVE", + ], + }, + }, + lowestRating: { + $min: { + $cond: [ + { + $and: [ + { $ne: ["$imdb.rating", null] }, + { $ne: ["$imdb.rating", ""] }, + { $eq: [{ $type: "$imdb.rating" }, "double"] }, + ], + }, + "$imdb.rating", + "$$REMOVE", + ], + }, + }, + totalVotes: { $sum: "$imdb.votes" }, + }, + }, + // STAGE 3: Shape final output + { + $project: { + year: "$_id", + movieCount: 1, + averageRating: { $round: ["$averageRating", 2] }, + highestRating: 1, + lowestRating: 1, + totalVotes: 1, + _id: 0, + }, + }, + // STAGE 4: Sort by year (newest first) + { + $sort: { year: -1 }, + }, + ]; + + const results = await moviesCollection.aggregate(pipeline).toArray(); + + res.json( + createSuccessResponse( + results, + `Aggregated statistics for ${results.length} years` + ) + ); +} + +/** + * GET /api/movies/aggregations/directors + * + * Aggregate directors with the most movies. + * Demonstrates MongoDB $unwind and $group for array aggregation. + */ +export async function getDirectorsWithMostMovies( + req: Request, + res: Response +): Promise { + const moviesCollection = getCollection("movies"); + const { limit = "20" } = req.query; + + const limitNum = Math.min(Math.max(parseInt(limit as string) || 20, 1), 100); + + const pipeline = [ + // STAGE 1: Data quality filter + { + $match: { + directors: { $exists: true, $ne: null, $not: { $eq: [] } }, + year: { $type: "number", $gte: 1800, $lte: 2030 }, + }, + }, + // STAGE 2: Unwind directors array + { + $unwind: "$directors", + }, + // STAGE 3: Clean director names + { + $match: { + directors: { $nin: [null, ""] }, + }, + }, + // STAGE 4: Group by director + { + $group: { + _id: "$directors", + movieCount: { $sum: 1 }, + averageRating: { $avg: "$imdb.rating" }, + }, + }, + // STAGE 5: Sort by movie count + { + $sort: { movieCount: -1 }, + }, + // STAGE 6: Limit results + { + $limit: limitNum, + }, + // STAGE 7: Shape final output + { + $project: { + director: "$_id", + movieCount: 1, + averageRating: { $round: ["$averageRating", 2] }, + _id: 0, + }, + }, + ]; + + const results = await moviesCollection.aggregate(pipeline).toArray(); + + res.json( + createSuccessResponse( + results, + `Found ${results.length} directors with most movies` + ) + ); +} + +/** + * Generates a vector embedding using the Voyage AI REST API. + * + * This function calls the Voyage AI API directly to generate embeddings with 2048 dimensions. + * The voyage-3-large model supports multiple dimensions (256, 512, 1024, 2048), and we explicitly + * request 2048 to match the vector search index configuration. + * + * @param text The text to generate an embedding for + * @param apiKey The Voyage AI API key + * @returns Promise representing the embedding vector + */ +async function generateVoyageEmbedding(text: string, apiKey: string): Promise { + // Build the request body with output_dimension set to 2048 + const requestBody = { + input: [text], + model: "voyage-3-large", + output_dimension: 2048, + input_type: "query" + }; + + try { + const response = await fetch("https://api.voyageai.com/v1/embeddings", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}` + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Voyage AI API returned status ${response.status}: ${errorText}`); + } + + const data = await response.json() as VoyageAIResponse; + + // Extract the embedding from the response + if (!data.data || !data.data[0] || !data.data[0].embedding) { + throw new Error("Invalid response format from Voyage AI API"); + } + + return data.data[0].embedding; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to generate embedding: ${error.message}`); + } + throw new Error("Failed to generate embedding: Unknown error"); + } +} diff --git a/server/express/src/routes/movies.ts b/server/express/src/routes/movies.ts index 2bb9cbf..5d6d6ac 100644 --- a/server/express/src/routes/movies.ts +++ b/server/express/src/routes/movies.ts @@ -29,6 +29,46 @@ const router = express.Router(); */ router.get("/", asyncHandler(movieController.getAllMovies)); +/** + * GET /api/movies/search + * + * Search movies using MongoDB Search across multiple fields. + * Demonstrates MongoDB Atlas Search with compound queries and fuzzy matching. + */ +router.get("/search", asyncHandler(movieController.searchMovies)); + +/** + * GET /api/movies/vector-search + * + * Search movies using MongoDB Vector Search for semantic similarity. + * Demonstrates vector search using embeddings to find similar plots. + */ +router.get("/vector-search", asyncHandler(movieController.vectorSearchMovies)); + +/** + * GET /api/movies/aggregations/reportingByComments + * + * Aggregate movies with their most recent comments. + * Demonstrates MongoDB $lookup aggregation to join collections. + */ +router.get("/aggregations/reportingByComments", asyncHandler(movieController.getMoviesWithMostRecentComments)); + +/** + * GET /api/movies/aggregations/reportingByYear + * + * Aggregate movies by year with statistics. + * Demonstrates MongoDB $group aggregation for statistical calculations. + */ +router.get("/aggregations/reportingByYear", asyncHandler(movieController.getMoviesByYearWithStats)); + +/** + * GET /api/movies/aggregations/reportingByDirectors + * + * Aggregate directors with the most movies. + * Demonstrates MongoDB $unwind and $group for array aggregation. + */ +router.get("/aggregations/reportingByDirectors", asyncHandler(movieController.getDirectorsWithMostMovies)); + /** * GET /api/movies/:id * diff --git a/server/express/src/types/index.ts b/server/express/src/types/index.ts index af5f127..5515c75 100644 --- a/server/express/src/types/index.ts +++ b/server/express/src/types/index.ts @@ -185,3 +185,137 @@ export type ErrorResponse = { }; export type ApiResponse = SuccessResponse | ErrorResponse; + +/** + * Interface for vector search results + */ +export interface VectorSearchResult { + _id: string; + title: string; + plot?: string; + poster?: string; + year?: number; + genres?: string[]; + directors?: string[]; + cast?: string[]; + score: number; +} + +/** + * Interface for director statistics aggregation results + */ +export interface DirectorStatisticsResult { + director: string; + movieCount: number; + averageRating?: number; +} + +/** + * Interface for movies by year aggregation results + */ +export interface MoviesByYearResult { + year: number; + movieCount: number; + averageRating?: number; + highestRating?: number; + lowestRating?: number; + totalVotes?: number; +} + +/** + * Interface for movies with comments aggregation results + */ +export interface MovieWithCommentsResult { + _id: string; + title: string; + year?: number; + plot?: string; + poster?: string; + genres?: string[]; + imdbRating?: number; + recentComments: CommentInfo[]; + totalComments: number; + mostRecentCommentDate?: Date; +} + +/** + * Interface for comment information in aggregation results + */ +export interface CommentInfo { + _id?: string; + userName: string; + userEmail: string; + text: string; + date: Date; +} + +/** + * Interface for search movies response with total count + */ +export interface SearchMoviesResponse { + movies: Movie[]; + totalCount: number; +} + +/** + * Interface for movie search request parameters + */ +export interface MovieSearchRequest { + plot?: string; + fullplot?: string; + directors?: string; + writers?: string; + cast?: string; + limit?: number; + skip?: number; + searchOperator?: string; +} + +/** + * Type for raw search query parameters from MongoDB Search endpoint + */ +export type RawMovieSearchQuery = { + plot?: string; + fullplot?: string; + directors?: string; + writers?: string; + cast?: string; + limit?: string; + skip?: string; + searchOperator?: string; +}; + +/** + * Interface for MongoDB Atlas Search phrase queries + */ +export interface SearchPhrase { + phrase?: { + query: string; + path: string; + }; + text?: { + query: string; + path: string; + fuzzy?: { maxEdits: number; prefixLength: number }; + }; +} + +/** + * Interface for aggregation comment data from MongoDB pipelines + */ +export interface AggregationComment { + _id?: ObjectId; + userName: string; + userEmail: string; + text: string; + date: Date; +} + +/** + * Interface for Voyage AI API response structure + */ +export interface VoyageAIResponse { + data: Array<{ + embedding: number[]; + }>; +} diff --git a/server/express/src/utils/errorHandler.ts b/server/express/src/utils/errorHandler.ts index 929cf29..4ddd5c6 100644 --- a/server/express/src/utils/errorHandler.ts +++ b/server/express/src/utils/errorHandler.ts @@ -135,7 +135,7 @@ function parseErrorDetails(err: Error): { * @returns Express middleware function */ export function asyncHandler( - fn: (req: Request, res: Response, next: NextFunction) => Promise + fn: (req: Request, res: Response, next: NextFunction) => Promise ) { return (req: Request, res: Response, next: NextFunction) => { try { diff --git a/server/express/tests/controllers/movieController.test.ts b/server/express/tests/controllers/movieController.test.ts index 3309932..e0928e9 100644 --- a/server/express/tests/controllers/movieController.test.ts +++ b/server/express/tests/controllers/movieController.test.ts @@ -7,35 +7,34 @@ 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"], - }, -]; +import { + TEST_OBJECT_IDS, + SAMPLE_REQUESTS, + SAMPLE_RESPONSES, + SAMPLE_MOVIE, + SAMPLE_MOVIES, + SAMPLE_SEARCH_RESULTS, + SAMPLE_VECTOR_RESULTS, + SAMPLE_VECTOR_MOVIES, + SAMPLE_COMMENTS_AGGREGATION, + SAMPLE_YEARS_AGGREGATION, + SAMPLE_DIRECTORS_AGGREGATION, + createMockRequest, + createMockResponse, + createMockVoyageResponse, + expectSuccessResponse, + expectErrorResponse +} from "../utils/testHelpers"; + +// Mock fetch globally for vector search tests +global.fetch = jest.fn(); + +// Test Data Constants - Using constants from testHelpers +const TEST_MOVIE_ID = TEST_OBJECT_IDS.VALID; +const INVALID_MOVIE_ID = TEST_OBJECT_IDS.INVALID; + +// Mock Voyage AI API response +const MOCK_VOYAGE_RESPONSE = createMockVoyageResponse(); // Create mock collection methods const mockFind = jest.fn(); @@ -47,6 +46,7 @@ const mockUpdateMany = jest.fn(); const mockDeleteOne = jest.fn(); const mockDeleteMany = jest.fn(); const mockFindOneAndDelete = jest.fn(); +const mockAggregate = jest.fn(); const mockToArray = jest.fn(); // Create mock database module @@ -65,6 +65,9 @@ const mockGetCollection = jest.fn(() => ({ deleteOne: mockDeleteOne, deleteMany: mockDeleteMany, findOneAndDelete: mockFindOneAndDelete, + aggregate: mockAggregate.mockReturnValue({ + toArray: mockToArray, + }), })); // Mock the database module @@ -112,64 +115,13 @@ import { deleteMovie, deleteMoviesBatch, findAndDeleteMovie, + searchMovies, + vectorSearchMovies, + getMoviesWithMostRecentComments, + getMoviesByYearWithStats, + getDirectorsWithMostMovies, } from "../../src/controllers/movieController"; -// Helper Functions -function createMockRequest(overrides: Partial = {}): Partial { - return { - query: {}, - params: {}, - body: {}, - ...overrides, - }; -} - -function createMockResponse(): { - mockJson: jest.Mock; - mockStatus: jest.Mock; - mockResponse: Partial; -} { - const mockJson = jest.fn(); - const mockStatus = jest.fn().mockReturnThis(); - - const mockResponse = { - json: mockJson, - status: mockStatus, - setHeader: jest.fn(), - }; - - return { mockJson, mockStatus, mockResponse }; -} - -function expectSuccessResponse( - mockCreateSuccessResponse: jest.Mock, - data: any, - message: string -) { - expect(mockCreateSuccessResponse).toHaveBeenCalledWith(data, message); -} - -function expectErrorResponse( - mockStatus: jest.Mock, - mockJson: jest.Mock, - statusCode: number, - errorMessage: string, - errorCode: string -) { - expect(mockStatus).toHaveBeenCalledWith(statusCode); - expect(mockCreateErrorResponse).toHaveBeenCalledWith(errorMessage, errorCode); - expect(mockJson).toHaveBeenCalledWith({ - success: false, - message: errorMessage, - error: { - message: errorMessage, - code: errorCode, - details: undefined, - }, - timestamp: "2024-01-01T00:00:00.000Z", - }); -} - describe("Movie Controller Tests", () => { let mockRequest: Partial; let mockResponse: Partial; @@ -314,8 +266,8 @@ describe("Movie Controller Tests", () => { describe("createMovie", () => { it("should successfully create a movie", async () => { - const movieData = { title: "New Movie", year: 2024 }; - const insertResult = { acknowledged: true, insertedId: new ObjectId() }; + const movieData = SAMPLE_REQUESTS.CREATE_MOVIE; + const insertResult = SAMPLE_RESPONSES.INSERT_ONE; const createdMovie = { _id: insertResult.insertedId, ...movieData }; mockRequest.body = movieData; @@ -368,12 +320,8 @@ describe("Movie Controller Tests", () => { 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()], - }; + const moviesData = SAMPLE_REQUESTS.BATCH_CREATE; + const insertResult = SAMPLE_RESPONSES.INSERT_MANY; mockRequest.body = moviesData; mockValidateRequiredFields.mockImplementation(() => {}); // Don't throw validation error @@ -678,4 +626,520 @@ describe("Movie Controller Tests", () => { ).rejects.toThrow(errorMessage); }); }); + + // ==================== SEARCH ENDPOINTS TESTS ==================== + + describe("searchMovies", () => { + beforeEach(() => { + // Reset environment variables + delete process.env.NODE_ENV; + }); + + it("should successfully search movies by plot", async () => { + const searchResults = [ + { + totalCount: [{ count: 2 }], + results: SAMPLE_SEARCH_RESULTS, + }, + ]; + + mockRequest.query = { plot: "space adventure" }; + mockToArray.mockResolvedValue(searchResults); + + await searchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockGetCollection).toHaveBeenCalledWith("movies"); + expect(mockAggregate).toHaveBeenCalled(); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { + movies: SAMPLE_SEARCH_RESULTS, + totalCount: 2, + }, + "Found 2 movies matching the search criteria" + ); + }); + + it("should handle search with multiple fields", async () => { + const searchResults = [ + { + totalCount: [{ count: 1 }], + results: [SAMPLE_SEARCH_RESULTS[0]], + }, + ]; + + mockRequest.query = { + plot: "space", + directors: "Nolan", + cast: "DiCaprio", + }; + mockToArray.mockResolvedValue(searchResults); + + await searchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockAggregate).toHaveBeenCalled(); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { + movies: [SAMPLE_SEARCH_RESULTS[0]], + totalCount: 1, + }, + "Found 1 movies matching the search criteria" + ); + }); + + it("should return 400 when no search parameters provided", async () => { + mockRequest.query = {}; + + await searchMovies(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "At least one search parameter must be provided", + "NO_SEARCH_PARAMETERS" + ); + }); + + it("should return 400 for invalid search operator", async () => { + mockRequest.query = { + plot: "adventure", + searchOperator: "invalid", + }; + + await searchMovies(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Invalid search_operator 'invalid'. Must be one of: must, should, mustNot, filter", + "INVALID_SEARCH_OPERATOR" + ); + }); + + it("should handle pagination parameters", async () => { + const searchResults = [ + { + totalCount: [{ count: 10 }], + results: SAMPLE_SEARCH_RESULTS, + }, + ]; + + mockRequest.query = { + plot: "adventure", + limit: "5", + skip: "10", + }; + mockToArray.mockResolvedValue(searchResults); + + await searchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { + movies: SAMPLE_SEARCH_RESULTS, + totalCount: 10, + }, + "Found 10 movies matching the search criteria" + ); + }); + + it("should return empty results when no matches found", async () => { + const searchResults = [ + { + totalCount: [], + results: [], + }, + ]; + + mockRequest.query = { plot: "nonexistent" }; + mockToArray.mockResolvedValue(searchResults); + + await searchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + { + movies: [], + totalCount: 0, + }, + "Found 0 movies matching the search criteria" + ); + }); + }); + + describe("vectorSearchMovies", () => { + const mockFetch = fetch as jest.MockedFunction; + + beforeEach(() => { + process.env.VOYAGE_API_KEY = "test-api-key"; + mockFetch.mockClear(); + }); + + afterEach(() => { + delete process.env.VOYAGE_API_KEY; + }); + + it("should successfully perform vector search", async () => { + // Mock Voyage AI API response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(MOCK_VOYAGE_RESPONSE), + } as any); + + // Ensure SAMPLE_VECTOR_RESULTS and SAMPLE_VECTOR_MOVIES have matching IDs + const vectorResultsWithMatchingIds = SAMPLE_VECTOR_RESULTS.map((result, index) => ({ + ...result, + _id: SAMPLE_VECTOR_MOVIES[index]._id, + })); + + // Mock database responses - first call for embedded_movies collection (vector search) + mockToArray + .mockResolvedValueOnce(vectorResultsWithMatchingIds) + // Second call for movies collection (complete movie data) + .mockResolvedValueOnce(SAMPLE_VECTOR_MOVIES); + + mockRequest.query = { q: "space adventure", limit: "3" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.voyageai.com/v1/embeddings", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-api-key", + }, + }) + ); + + // Should call embedded_movies collection first, then movies collection + expect(mockGetCollection).toHaveBeenCalledWith("embedded_movies"); + expect(mockGetCollection).toHaveBeenCalledWith("movies"); + expect(mockAggregate).toHaveBeenCalledTimes(2); + + // Verify the final result structure includes all movie fields + const expectedResults = SAMPLE_VECTOR_MOVIES.map((movie, index) => ({ + _id: movie._id.toString(), + title: movie.title, + plot: movie.plot, + poster: movie.poster, + year: movie.year, + genres: movie.genres, + directors: movie.directors, + cast: movie.cast, + score: SAMPLE_VECTOR_RESULTS[index].score, + })); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + expectedResults, + "Found 2 similar movies for query: 'space adventure'" + ); + }); + + it("should return 400 when query is missing", async () => { + mockRequest.query = {}; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Search query is required", + "MISSING_QUERY_PARAMETER" + ); + }); + + it("should return 400 when query is empty", async () => { + mockRequest.query = { q: "" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Search query is required", + "MISSING_QUERY_PARAMETER" + ); + }); + + it("should return 400 when VOYAGE_API_KEY is not configured", async () => { + delete process.env.VOYAGE_API_KEY; + + mockRequest.query = { q: "test" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + "SERVICE_UNAVAILABLE" + ); + }); + + it("should handle Voyage AI API errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + } as any); + + mockRequest.query = { q: "test" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(500); + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + "Error performing vector search", + "VECTOR_SEARCH_ERROR", + expect.stringContaining("Voyage AI API returned status 401") + ); + }); + + it("should use default limit when not provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(MOCK_VOYAGE_RESPONSE), + } as any); + + // Mock empty results from vector search + mockToArray + .mockResolvedValueOnce([]) // empty vector search results + .mockResolvedValueOnce([]); // empty movie results + + mockRequest.query = { q: "test" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + [], + "No similar movies found for query: 'test'" + ); + }); + }); + + // ==================== AGGREGATION ENDPOINTS TESTS ==================== + + describe("getMoviesWithMostRecentComments", () => { + beforeEach(() => { + // Reset mocks for aggregation tests to avoid interference from vector search tests + mockToArray.mockReset(); + }); + + it("should successfully get movies with comments", async () => { + mockRequest.query = { limit: "10" }; + mockToArray.mockResolvedValue(SAMPLE_COMMENTS_AGGREGATION); + + await getMoviesWithMostRecentComments( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockGetCollection).toHaveBeenCalledWith("movies"); + expect(mockAggregate).toHaveBeenCalled(); + + const expectedResults = SAMPLE_COMMENTS_AGGREGATION.map((result) => ({ + _id: result._id.toString(), + title: result.title, + year: result.year, + genres: result.genres, + imdbRating: result.imdbRating, + recentComments: result.recentComments.map((comment) => ({ + _id: comment._id?.toString(), + userName: comment.userName, + userEmail: comment.userEmail, + text: comment.text, + date: comment.date, + })), + totalComments: result.totalComments, + })); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + expectedResults, + "Found 5 comments from 1 movie" + ); + }); + + it("should filter by specific movieId when provided", async () => { + mockRequest.query = { movieId: TEST_MOVIE_ID }; + mockToArray.mockResolvedValue(SAMPLE_COMMENTS_AGGREGATION); + + await getMoviesWithMostRecentComments( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockAggregate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + $match: expect.objectContaining({ + _id: new ObjectId(TEST_MOVIE_ID), + }), + }), + ]) + ); + }); + + it("should return 400 for invalid movieId format", async () => { + mockRequest.query = { movieId: INVALID_MOVIE_ID }; + + await getMoviesWithMostRecentComments( + mockRequest as Request, + mockResponse as Response + ); + + expectErrorResponse( + mockStatus, + mockJson, + 400, + "Invalid movie ID format", + "INVALID_OBJECT_ID" + ); + }); + + it("should handle empty results", async () => { + mockRequest.query = {}; + mockToArray.mockResolvedValue([]); + + await getMoviesWithMostRecentComments( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + [], + "Found 0 comments from 0 movies" + ); + }); + + it("should use default limit when not provided", async () => { + mockRequest.query = {}; + mockToArray.mockResolvedValue(SAMPLE_COMMENTS_AGGREGATION); + + await getMoviesWithMostRecentComments( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockAggregate).toHaveBeenCalled(); + }); + }); + + describe("getMoviesByYearWithStats", () => { + it("should successfully get movies statistics by year", async () => { + mockRequest.query = {}; + mockToArray.mockResolvedValue(SAMPLE_YEARS_AGGREGATION); + + await getMoviesByYearWithStats( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockGetCollection).toHaveBeenCalledWith("movies"); + expect(mockAggregate).toHaveBeenCalled(); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + SAMPLE_YEARS_AGGREGATION, + "Aggregated statistics for 2 years" + ); + }); + + it("should handle empty results", async () => { + mockRequest.query = {}; + mockToArray.mockResolvedValue([]); + + await getMoviesByYearWithStats( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + [], + "Aggregated statistics for 0 years" + ); + }); + + it("should handle database errors", async () => { + mockRequest.query = {}; + const errorMessage = "Aggregation failed"; + mockToArray.mockRejectedValue(new Error(errorMessage)); + + await expect( + getMoviesByYearWithStats(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow(errorMessage); + }); + }); + + describe("getDirectorsWithMostMovies", () => { + it("should successfully get directors statistics", async () => { + mockRequest.query = { limit: "20" }; + mockToArray.mockResolvedValue(SAMPLE_DIRECTORS_AGGREGATION); + + await getDirectorsWithMostMovies( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockGetCollection).toHaveBeenCalledWith("movies"); + expect(mockAggregate).toHaveBeenCalled(); + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + SAMPLE_DIRECTORS_AGGREGATION, + "Found 2 directors with most movies" + ); + }); + + it("should handle custom limit parameter", async () => { + mockRequest.query = { limit: "10" }; + mockToArray.mockResolvedValue(SAMPLE_DIRECTORS_AGGREGATION.slice(0, 1)); + + await getDirectorsWithMostMovies( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + SAMPLE_DIRECTORS_AGGREGATION.slice(0, 1), + "Found 1 directors with most movies" + ); + }); + + it("should use default limit when not provided", async () => { + mockRequest.query = {}; + mockToArray.mockResolvedValue(SAMPLE_DIRECTORS_AGGREGATION); + + await getDirectorsWithMostMovies( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockAggregate).toHaveBeenCalled(); + }); + + it("should handle empty results", async () => { + mockRequest.query = {}; + mockToArray.mockResolvedValue([]); + + await getDirectorsWithMostMovies( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockCreateSuccessResponse).toHaveBeenCalledWith( + [], + "Found 0 directors with most movies" + ); + }); + + it("should handle database errors", async () => { + mockRequest.query = {}; + const errorMessage = "Aggregation pipeline failed"; + mockToArray.mockRejectedValue(new Error(errorMessage)); + + await expect( + getDirectorsWithMostMovies(mockRequest as Request, mockResponse as Response) + ).rejects.toThrow(errorMessage); + }); + }); }); diff --git a/server/express/tests/utils/testHelpers.ts b/server/express/tests/utils/testHelpers.ts new file mode 100644 index 0000000..e98f9c0 --- /dev/null +++ b/server/express/tests/utils/testHelpers.ts @@ -0,0 +1,278 @@ +/** + * Test Utilities and Helpers + * + * This file contains common utilities, test data, and helper functions + * used across unit tests to ensure consistency and reduce duplication. + */ + +import { ObjectId } from "mongodb"; +import { Request, Response } from "express"; + +export const TEST_OBJECT_IDS = { + VALID: "507f1f77bcf86cd799439011", + VALID_2: "507f1f77bcf86cd799439012", + INVALID: "invalid-id", +}; + +// ==================== SAMPLE MOVIE DATA ==================== + +export const SAMPLE_MOVIE = { + _id: TEST_OBJECT_IDS.VALID, + title: "Test Movie", + year: 2024, + plot: "A test movie", + genres: ["Action"], +}; + +export const SAMPLE_MOVIES = [ + { + _id: TEST_OBJECT_IDS.VALID, + title: "Test Movie 1", + year: 2024, + plot: "A test movie", + genres: ["Action"], + }, + { + _id: TEST_OBJECT_IDS.VALID + "-b", + title: "Test Movie 2", + year: 2024, + plot: "Another test movie", + genres: ["Comedy"], + }, +]; + +export const SAMPLE_SEARCH_RESULTS = [ + { + _id: new ObjectId(), + title: "Space Adventure", + year: 2024, + plot: "An epic space adventure across the galaxy", + genres: ["Sci-Fi", "Adventure"], + }, + { + _id: new ObjectId(), + title: "Space Quest", + year: 2023, + plot: "A thrilling space adventure to save humanity", + genres: ["Sci-Fi", "Action"], + }, +]; + +export const SAMPLE_VECTOR_RESULTS = [ + { + _id: new ObjectId(), + score: 0.85, + }, + { + _id: new ObjectId(), + score: 0.78, + }, +]; + +export const SAMPLE_VECTOR_MOVIES = [ + { + _id: new ObjectId(), + title: "Space Raiders", + plot: "A futuristic space adventure", + poster: "https://example.com/poster1.jpg", + year: 2024, + genres: ["Sci-Fi", "Action"], + directors: ["John Director"], + cast: ["Actor One", "Actor Two"], + }, + { + _id: new ObjectId(), + title: "Galaxy Quest", + plot: "An epic space journey", + poster: "https://example.com/poster2.jpg", + year: 2023, + genres: ["Sci-Fi", "Comedy"], + directors: ["Jane Director"], + cast: ["Actor Three", "Actor Four"], + }, +]; + +export const SAMPLE_COMMENTS_AGGREGATION = [ + { + _id: new ObjectId(), + title: "Test Movie", + year: 2024, + genres: ["Action", "Drama"], + imdbRating: 8.5, + recentComments: [ + { + _id: new ObjectId(), + userName: "John Doe", + userEmail: "john@example.com", + text: "Great movie!", + date: new Date("2024-01-01"), + }, + ], + totalComments: 5, + }, +]; + +export const SAMPLE_YEARS_AGGREGATION = [ + { + year: 2024, + movieCount: 10, + averageRating: 7.5, + highestRating: 9.0, + lowestRating: 6.0, + totalVotes: 5000, + }, + { + year: 2023, + movieCount: 15, + averageRating: 7.8, + highestRating: 9.5, + lowestRating: 6.5, + totalVotes: 7500, + }, +]; + +export const SAMPLE_DIRECTORS_AGGREGATION = [ + { + director: "Christopher Nolan", + movieCount: 10, + averageRating: 8.5, + }, + { + director: "Steven Spielberg", + movieCount: 25, + averageRating: 8.2, + }, +]; + +// ==================== REQUEST/RESPONSE DATA ==================== + +export const SAMPLE_REQUESTS = { + CREATE_MOVIE: { + title: "New Movie", + year: 2024, + plot: "A new movie plot", + genres: ["Action"], + }, + UPDATE_MOVIE: { + title: "Updated Movie Title", + year: 2025, + plot: "Updated plot", + }, + BATCH_CREATE: [ + { title: "Batch Movie 1", year: 2024 }, + { title: "Batch Movie 2", year: 2024 }, + ], +}; + +export const SAMPLE_RESPONSES = { + INSERT_ONE: { + acknowledged: true, + insertedId: new ObjectId(), + }, + INSERT_MANY: { + acknowledged: true, + insertedCount: 2, + insertedIds: [new ObjectId(), new ObjectId()], + }, + UPDATE_ONE: { + matchedCount: 1, + modifiedCount: 1, + }, + UPDATE_MANY: { + matchedCount: 5, + modifiedCount: 3, + }, + DELETE_ONE: { + deletedCount: 1, + }, + DELETE_MANY: { + deletedCount: 10, + }, +}; + +// ==================== MOCK HELPER FUNCTIONS ==================== + +/** + * Creates mock Express request object with default values + */ +export function createMockRequest(overrides: Partial = {}): Partial { + return { + query: {}, + params: {}, + body: {}, + ...overrides, + }; +} + +/** + * Creates mock Express response object with spies + */ +export 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 }; +} + +/** + * Creates mock Voyage AI API response for vector search tests + */ +export function createMockVoyageResponse(dimensions = 2048) { + return { + data: [ + { + embedding: new Array(dimensions).fill(0.1), + }, + ], + }; +} + +// ==================== TEST ASSERTION HELPERS ==================== + +/** + * Asserts that a success response was created correctly + */ +export function expectSuccessResponse( + mockCreateSuccessResponse: jest.Mock, + data: any, + message: string +) { + expect(mockCreateSuccessResponse).toHaveBeenCalledWith(data, message); +} + +/** + * Asserts that an error response was created correctly + */ +export function expectErrorResponse( + mockStatus: jest.Mock, + mockJson: jest.Mock, + statusCode: number, + errorMessage: string, + errorCode: string, + mockCreateErrorResponse?: jest.Mock +) { + expect(mockStatus).toHaveBeenCalledWith(statusCode); + if (mockCreateErrorResponse) { + 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", + }); +} \ No newline at end of file