From 58dbd48bc64cf32ef0f84588507f2b6fe8f68e30 Mon Sep 17 00:00:00 2001 From: Taylor McNeil Date: Wed, 18 Feb 2026 15:22:25 -0500 Subject: [PATCH 1/3] init commit --- .../python-fastapi/src/routers/movies.py | 162 +++++---- .../python-fastapi/src/utils/response_docs.py | 314 ++++++++++++++++++ 2 files changed, 420 insertions(+), 56 deletions(-) create mode 100644 mflix/server/python-fastapi/src/utils/response_docs.py diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 1da9b6a..60c3563 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -5,6 +5,15 @@ from typing import Any, List, Optional from src.utils.successResponse import create_success_response from src.utils.errorResponse import create_error_response +from src.utils.response_docs import ( + VECTOR_SEARCH_RESPONSES, + OBJECTID_VALIDATION_RESPONSES, + SEARCH_ENDPOINT_RESPONSES, + FASTAPI_400_MISSING_SEARCH_PARAMS, + DATABASE_OPERATION_RESPONSES, + CRUD_OPERATION_RESPONSES, + CRUD_WITH_OBJECTID_RESPONSES +) from src.utils.exceptions import VoyageAuthError, VoyageAPIError from bson import ObjectId, errors import re @@ -114,7 +123,11 @@ "/search", response_model=SuccessResponse[SearchMoviesResponse], status_code = 200, - summary="Search movies using MongoDB Search." + summary="Search movies using MongoDB Search.", + responses={ + **SEARCH_ENDPOINT_RESPONSES, + 400: FASTAPI_400_MISSING_SEARCH_PARAMS + } ) async def search_movies( plot: Optional[str] = None, @@ -324,7 +337,11 @@ async def search_movies( outputDimension = 2048 #Set to 2048 to match the dimensions of the collection's embeddings # Vector Search Endpoint -@router.get("/vector-search", response_model=SuccessResponse[List[VectorSearchResult]]) +@router.get( + "/vector-search", + response_model=SuccessResponse[List[VectorSearchResult]], + responses=VECTOR_SEARCH_RESPONSES +) async def vector_search_movies( q: str = Query(..., description="Search query to find similar movies by plot"), limit: int = Query(default=10, ge=1, le=50, description="Number of results to return") @@ -441,10 +458,13 @@ async def vector_search_movies( SuccessResponse[List[str]]: A response object containing the list of unique genres, sorted alphabetically. """ -@router.get("/genres", - response_model=SuccessResponse[List[str]], - status_code=200, - summary="Retrieve all distinct genres from the movies collection.") +@router.get( + "/genres", + response_model=SuccessResponse[List[str]], + status_code=200, + summary="Retrieve all distinct genres from the movies collection.", + responses=DATABASE_OPERATION_RESPONSES +) async def get_distinct_genres(): movies_collection = get_collection("movies") @@ -475,10 +495,13 @@ async def get_distinct_genres(): SuccessResponse[Movie]: A response object containing the movie data. """ -@router.get("/{id}", - response_model=SuccessResponse[Movie], - status_code = 200, - summary="Retrieve a single movie by its ID.") +@router.get( + "/{id}", + response_model=SuccessResponse[Movie], + status_code = 200, + summary="Retrieve a single movie by its ID.", + responses=OBJECTID_VALIDATION_RESPONSES +) async def get_movie_by_id(id: str): # Validate ObjectId format try: @@ -529,10 +552,13 @@ async def get_movie_by_id(id: str): SuccessResponse[List[Movie]]: A response object containing the list of movies and metadata. """ -@router.get("/", - response_model=SuccessResponse[List[Movie]], - status_code = 200, - summary="Retrieve a list of movies with optional filtering, sorting, and pagination.") +@router.get( + "/", + response_model=SuccessResponse[List[Movie]], + status_code = 200, + summary="Retrieve a list of movies with optional filtering, sorting, and pagination.", + responses=DATABASE_OPERATION_RESPONSES +) # Validate the query parameters using FastAPI's Query functionality. async def get_all_movies( q:str = Query(default=None), @@ -608,10 +634,13 @@ async def get_all_movies( SuccessResponse[Movie]: A response object containing the created movie data. """ -@router.post("/", - response_model=SuccessResponse[Movie], - status_code = 201, - summary="Creates a new movie in the database.") +@router.post( + "/", + response_model=SuccessResponse[Movie], + status_code = 201, + summary="Creates a new movie in the database.", + responses=CRUD_OPERATION_RESPONSES +) async def create_movie(movie: CreateMovieRequest): # Pydantic automatically validates the structure movie_data = movie.model_dump(by_alias=True, exclude_none=True) @@ -678,11 +707,12 @@ async def create_movie(movie: CreateMovieRequest): """ @router.post( - "/batch", - response_model=SuccessResponse[dict], - status_code = 201, - summary = "Create multiple movies in a single request." - ) + "/batch", + response_model=SuccessResponse[dict], + status_code = 201, + summary = "Create multiple movies in a single request.", + responses=CRUD_OPERATION_RESPONSES +) async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessResponse[dict]: movies_collection = get_collection("movies") @@ -730,10 +760,12 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons SuccessResponse: The updated movie document, the number of fields modified and a success message. """ @router.patch( - "/{id}", - response_model=SuccessResponse[Movie], - status_code = 200, - summary="Update a single movie by its ID.") + "/{id}", + response_model=SuccessResponse[Movie], + status_code = 200, + summary="Update a single movie by its ID.", + responses=CRUD_WITH_OBJECTID_RESPONSES +) async def update_movie( movie_data: UpdateMovieRequest, movie_id: str = Path(..., alias="id") @@ -793,11 +825,13 @@ async def update_movie( SuccessResponse: A response object containing the number of matched and modified movies and a success message. """ -@router.patch("/", - response_model=SuccessResponse[dict], - status_code = 200, - summary="Batch update movies matching the given filter." - ) +@router.patch( + "/", + response_model=SuccessResponse[dict], + status_code = 200, + summary="Batch update movies matching the given filter.", + responses=CRUD_OPERATION_RESPONSES +) async def update_movies_batch( request_body: dict = Body(...) ) -> SuccessResponse[dict]: @@ -849,10 +883,13 @@ async def update_movies_batch( SuccessResponse[dict]: A response object containing deletion details. """ -@router.delete("/{id}", - response_model=SuccessResponse[dict], - status_code = 200, - summary="Delete a single movie by its ID.") +@router.delete( + "/{id}", + response_model=SuccessResponse[dict], + status_code = 200, + summary="Delete a single movie by its ID.", + responses=OBJECTID_VALIDATION_RESPONSES +) async def delete_movie_by_id(id: str): try: object_id = ObjectId(id) @@ -896,10 +933,11 @@ async def delete_movie_by_id(id: str): """ @router.delete( - "/", - response_model=SuccessResponse[dict], - status_code = 200, - summary="Delete multiple movies matching the given filter." + "/", + response_model=SuccessResponse[dict], + status_code = 200, + summary="Delete multiple movies matching the given filter.", + responses=CRUD_OPERATION_RESPONSES ) async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse[dict]: @@ -949,10 +987,13 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse SuccessResponse[Movie]: A response object containing the deleted movie data. """ -@router.delete("/{id}/find-and-delete", - response_model=SuccessResponse[Movie], - status_code = 200, - summary="Find and delete a movie in a single operation.") +@router.delete( + "/{id}/find-and-delete", + response_model=SuccessResponse[Movie], + status_code = 200, + summary="Find and delete a movie in a single operation.", + responses=OBJECTID_VALIDATION_RESPONSES +) async def find_and_delete_movie(id: str): try: object_id = ObjectId(id) @@ -994,10 +1035,13 @@ async def find_and_delete_movie(id: str): SuccessResponse[List[dict]]: A response object containing movies with their most recent comments. """ -@router.get("/aggregations/reportingByComments", - response_model=SuccessResponse[List[dict]], - status_code = 200, - summary="Aggregate movies with their most recent comments.") +@router.get( + "/aggregations/reportingByComments", + response_model=SuccessResponse[List[dict]], + status_code = 200, + summary="Aggregate movies with their most recent comments.", + responses=DATABASE_OPERATION_RESPONSES +) async def aggregate_movies_recent_commented( limit: int = Query(default=10, ge=1, le=50), movie_id: str = Query(default=None) @@ -1142,10 +1186,13 @@ async def aggregate_movies_recent_commented( SuccessResponse[List[dict]]: A response object containing yearly movie statistics. """ -@router.get("/aggregations/reportingByYear", - response_model=SuccessResponse[List[dict]], - status_code = 200, - summary="Aggregate movies by year with average rating and movie count.") +@router.get( + "/aggregations/reportingByYear", + response_model=SuccessResponse[List[dict]], + status_code = 200, + summary="Aggregate movies by year with average rating and movie count.", + responses=DATABASE_OPERATION_RESPONSES +) async def aggregate_movies_by_year(): # Define aggregation pipeline to group movies by year with statistics # This pipeline demonstrates grouping, statistical calculations, and data cleaning @@ -1266,10 +1313,13 @@ async def aggregate_movies_by_year(): SuccessResponse[List[dict]]: A response object containing director statistics. """ -@router.get("/aggregations/reportingByDirectors", - response_model=SuccessResponse[List[dict]], - status_code = 200, - summary="Aggregate directors with the most movies and their statistics.") +@router.get( + "/aggregations/reportingByDirectors", + response_model=SuccessResponse[List[dict]], + status_code = 200, + summary="Aggregate directors with the most movies and their statistics.", + responses=DATABASE_OPERATION_RESPONSES +) async def aggregate_directors_most_movies( limit: int = Query(default=20, ge=1, le=100) ): diff --git a/mflix/server/python-fastapi/src/utils/response_docs.py b/mflix/server/python-fastapi/src/utils/response_docs.py new file mode 100644 index 0000000..c9a4531 --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/response_docs.py @@ -0,0 +1,314 @@ +""" +OpenAPI Response Documentation Helpers + +This module provides reusable response documentation for FastAPI endpoints +to maintain consistent OpenAPI documentation across all movie API endpoints. +Supports both FastAPI standard format and custom application error format. +""" + +# FastAPI Standard Error Responses (HTTPException format) +FASTAPI_400_INVALID_OBJECTID = { + "description": "Bad Request - Invalid ObjectId format", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "The provided ID 'invalid_id' is not a valid ObjectId" + } + } + } + } +} + +FASTAPI_400_INVALID_SEARCH_OPERATOR = { + "description": "Bad Request - Invalid search operator", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "Invalid search operator 'invalid'. The search operator must be one of {'must', 'should', 'mustNot', 'filter'}." + } + } + } + } +} + +FASTAPI_400_VALIDATION_ERROR = { + "description": "Bad Request - Request validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "Invalid request body format" + } + } + } + } +} + +FASTAPI_422_VALIDATION_ERROR = { + "description": "Unprocessable Entity - Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "array", + "items": { + "type": "object", + "properties": { + "loc": {"type": "array"}, + "msg": {"type": "string"}, + "type": {"type": "string"} + } + } + } + }, + "example": { + "detail": [ + { + "loc": ["body", "title"], + "msg": "field required", + "type": "value_error.missing" + } + ] + } + } + } + } +} + +FASTAPI_404_MOVIE_NOT_FOUND = { + "description": "Not Found - Movie not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "No movie found with ID: 507f1f77bcf86cd799439011" + } + } + } + } +} + +FASTAPI_500_DATABASE_ERROR = { + "description": "Internal Server Error - Database operation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "Database error occurred: Connection timeout" + } + } + } + } +} + +FASTAPI_500_SEARCH_ERROR = { + "description": "Internal Server Error - Search operation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "An error occurred while performing the search: Index not found" + } + } + } + } +} + +FASTAPI_500_VECTOR_SEARCH_ERROR = { + "description": "Internal Server Error - Vector search operation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "Error performing vector search: Embedding generation failed" + } + } + } + } +} + +FASTAPI_400_MISSING_SEARCH_PARAMS = { + "description": "Bad Request - Missing search parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"} + }, + "example": { + "detail": "At least one search parameter must be provided." + } + } + } + } +} + +# Custom Application Error Responses (create_error_response format) +CUSTOM_400_VOYAGE_SERVICE_UNAVAILABLE = { + "description": "Bad Request - Vector search service unavailable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "error": { + "type": "object", + "properties": { + "message": {"type": "string"}, + "code": {"type": "string"}, + "details": {"type": "string"} + } + }, + "timestamp": {"type": "string"} + }, + "example": { + "success": False, + "message": "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + "error": { + "message": "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + "code": "SERVICE_UNAVAILABLE", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" + } + } + } + } +} + +CUSTOM_401_VOYAGE_AUTH_ERROR = { + "description": "Unauthorized - Invalid Voyage AI API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "error": { + "type": "object", + "properties": { + "message": {"type": "string"}, + "code": {"type": "string"}, + "details": {"type": "string"} + } + }, + "timestamp": {"type": "string"} + }, + "example": { + "success": False, + "message": "Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file", + "error": { + "message": "Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file", + "code": "VOYAGE_AUTH_ERROR", + "details": "Please verify your VOYAGE_API_KEY is correct in the .env file" + }, + "timestamp": "2024-01-01T12:00:00.000Z" + } + } + } + } +} + +CUSTOM_503_VOYAGE_API_ERROR = { + "description": "Service Unavailable - Voyage AI API error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "error": { + "type": "object", + "properties": { + "message": {"type": "string"}, + "code": {"type": "string"}, + "details": {"type": "string"} + } + }, + "timestamp": {"type": "string"} + }, + "example": { + "success": False, + "message": "Vector search service unavailable", + "error": { + "message": "Voyage AI API returned status 503: Service temporarily unavailable", + "code": "VOYAGE_API_ERROR", + "details": "Voyage AI API returned status 503: Service temporarily unavailable" + }, + "timestamp": "2024-01-01T12:00:00.000Z" + } + } + } + } +} + +# Common response combinations for different endpoint types +OBJECTID_VALIDATION_RESPONSES = { + 400: FASTAPI_400_INVALID_OBJECTID, + 404: FASTAPI_404_MOVIE_NOT_FOUND, + 500: FASTAPI_500_DATABASE_ERROR +} + +SEARCH_ENDPOINT_RESPONSES = { + 400: FASTAPI_400_INVALID_SEARCH_OPERATOR, + 500: FASTAPI_500_SEARCH_ERROR +} + +VECTOR_SEARCH_RESPONSES = { + 400: CUSTOM_400_VOYAGE_SERVICE_UNAVAILABLE, + 401: CUSTOM_401_VOYAGE_AUTH_ERROR, + 500: FASTAPI_500_VECTOR_SEARCH_ERROR, + 503: CUSTOM_503_VOYAGE_API_ERROR +} + +DATABASE_OPERATION_RESPONSES = { + 500: FASTAPI_500_DATABASE_ERROR +} + +CRUD_OPERATION_RESPONSES = { + 400: FASTAPI_400_VALIDATION_ERROR, + 422: FASTAPI_422_VALIDATION_ERROR, + 500: FASTAPI_500_DATABASE_ERROR +} + +CRUD_WITH_OBJECTID_RESPONSES = { + **OBJECTID_VALIDATION_RESPONSES, + 422: FASTAPI_422_VALIDATION_ERROR +} \ No newline at end of file From 2519eaad027e118264b6dd5bdadc91c7a4785aa6 Mon Sep 17 00:00:00 2001 From: Taylor McNeil Date: Mon, 23 Feb 2026 15:07:16 -0500 Subject: [PATCH 2/3] - addressed pr feedback - addressed differences in python and express backend responses --- .../python-fastapi/src/routers/movies.py | 321 ++++++++++------ .../python-fastapi/src/utils/response_docs.py | 348 +++++++++--------- 2 files changed, 389 insertions(+), 280 deletions(-) diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 60c3563..50170eb 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Query, Path, Body, HTTPException +from fastapi import APIRouter, Query, Path, Body from fastapi.responses import JSONResponse from src.database.mongo_client import get_collection, voyage_ai_available from src.models.models import VectorSearchResult, CreateMovieRequest, Movie, SuccessResponse, UpdateMovieRequest, SearchMoviesResponse @@ -9,7 +9,6 @@ VECTOR_SEARCH_RESPONSES, OBJECTID_VALIDATION_RESPONSES, SEARCH_ENDPOINT_RESPONSES, - FASTAPI_400_MISSING_SEARCH_PARAMS, DATABASE_OPERATION_RESPONSES, CRUD_OPERATION_RESPONSES, CRUD_WITH_OBJECTID_RESPONSES @@ -124,10 +123,7 @@ response_model=SuccessResponse[SearchMoviesResponse], status_code = 200, summary="Search movies using MongoDB Search.", - responses={ - **SEARCH_ENDPOINT_RESPONSES, - 400: FASTAPI_400_MISSING_SEARCH_PARAMS - } + responses=SEARCH_ENDPOINT_RESPONSES ) async def search_movies( plot: Optional[str] = None, @@ -146,9 +142,12 @@ async def search_movies( valid_operators = {"must", "should", "mustNot", "filter"} if search_operator not in valid_operators: - raise HTTPException( - status_code = 400, - detail=f"Invalid search operator '{search_operator}'. The search operator must be one of {valid_operators}." + return JSONResponse( + status_code=400, + content=create_error_response( + message=f"Invalid search operator '{search_operator}'. The search operator must be one of {valid_operators}.", + code="INVALID_SEARCH_OPERATOR" + ) ) # Build the search_phrases list based on which fields were provided by the user. @@ -220,9 +219,12 @@ async def search_movies( }) if not search_phrases: - raise HTTPException( - status_code = 400, - detail="At least one search parameter must be provided." + return JSONResponse( + status_code=400, + content=create_error_response( + message="At least one search parameter must be provided.", + code="MISSING_SEARCH_PARAMS" + ) ) # Build the aggregation pipeline for MongoDB Search. @@ -275,9 +277,12 @@ async def search_movies( try: results = await execute_aggregation(aggregation_pipeline) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"An error occurred while performing the search: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"An error occurred while performing the search: {str(e)}", + code="SEARCH_ERROR" + ) ) @@ -359,7 +364,7 @@ async def vector_search_movies( # Check if Voyage AI API key is configured if not voyage_ai_available(): return JSONResponse( - status_code=400, + status_code=503, content=create_error_response( message="Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", code="SERVICE_UNAVAILABLE" @@ -443,9 +448,12 @@ async def vector_search_movies( print(f"Vector search error: {str(e)}") # Handle generic errors - raise HTTPException( + return JSONResponse( status_code=500, - detail=f"Error performing vector search: {str(e)}" + content=create_error_response( + message=f"Error performing vector search: {str(e)}", + code="VECTOR_SEARCH_ERROR" + ) ) """ @@ -473,9 +481,12 @@ async def get_distinct_genres(): # MongoDB automatically flattens array fields when using distinct() genres = await movies_collection.distinct("genres") except Exception as e: - raise HTTPException( + return JSONResponse( status_code=500, - detail=f"Database error occurred: {str(e)}" + content=create_error_response( + message=f"Database error occurred: {str(e)}", + code="DATABASE_ERROR" + ) ) # Filter out null/empty values and sort alphabetically @@ -507,25 +518,34 @@ async def get_movie_by_id(id: str): try: object_id = ObjectId(id) except errors.InvalidId: - raise HTTPException( - status_code = 400, - detail=f"The provided ID '{id}' is not a valid ObjectId" + return JSONResponse( + status_code=400, + content=create_error_response( + message=f"The provided ID '{id}' is not a valid ObjectId", + code="INVALID_OBJECT_ID" + ) ) movies_collection = get_collection("movies") try: movie = await movies_collection.find_one({"_id": object_id}) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred: {str(e)}", + code="DATABASE_ERROR" + ) ) if movie is None: - raise HTTPException( - status_code = 404, - detail=f"No movie found with ID: {id}" + return JSONResponse( + status_code=404, + content=create_error_response( + message=f"No movie found with ID: {id}", + code="MOVIE_NOT_FOUND" + ) ) movie["_id"] = str(movie["_id"]) # Convert ObjectId to string @@ -600,9 +620,12 @@ async def get_all_movies( try: result = movies_collection.find(filter_dict).sort(sort).skip(skip).limit(limit) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"An error occurred while fetching movies. {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"An error occurred while fetching movies. {str(e)}", + code="DATABASE_ERROR" + ) ) movies = [] @@ -649,31 +672,43 @@ async def create_movie(movie: CreateMovieRequest): try: result = await movies_collection.insert_one(movie_data) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred: {str(e)}", + code="DATABASE_ERROR" + ) ) # Verify that the document was created before querying it if not result.acknowledged: - raise HTTPException( - status_code = 500, - detail="Failed to create movie: The database did not acknowledge the insert operation" + return JSONResponse( + status_code=500, + content=create_error_response( + message="Failed to create movie: The database did not acknowledge the insert operation", + code="DATABASE_ERROR" + ) ) try: # Retrieve the created document to return complete data created_movie = await movies_collection.find_one({"_id": result.inserted_id}) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred: {str(e)}", + code="DATABASE_ERROR" + ) ) if created_movie is None: - raise HTTPException( - status_code = 500, - detail="Movie was created but could not be retrieved for verification" + return JSONResponse( + status_code=500, + content=create_error_response( + message="Movie was created but could not be retrieved for verification", + code="DATABASE_ERROR" + ) ) created_movie["_id"] = str(created_movie["_id"]) # Convert ObjectId to string @@ -718,9 +753,12 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons #Verify that the movies list is not empty if not movies: - raise HTTPException( - status_code = 400, - detail="Request body must be a non-empty list of movies." + return JSONResponse( + status_code=400, + content=create_error_response( + message="Request body must be a non-empty list of movies.", + code="EMPTY_REQUEST" + ) ) movies_dicts = [] @@ -740,9 +778,12 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons f"Successfully created {len(result.inserted_ids)} movies." ) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred: {str(e)}", + code="DATABASE_ERROR" + ) ) """ @@ -777,18 +818,24 @@ async def update_movie( try: movie_id = ObjectId(movie_id) except Exception : - raise HTTPException( - status_code = 400, - detail=f"Invalid movie_id format: {movie_id}" + return JSONResponse( + status_code=400, + content=create_error_response( + message=f"Invalid movie_id format: {movie_id}", + code="INVALID_OBJECT_ID" + ) ) update_dict = movie_data.model_dump(exclude_unset=True, exclude_none=True) # Validate that the dict is not empty if not update_dict: - raise HTTPException( - status_code = 400, - detail="No valid fields provided for update." + return JSONResponse( + status_code=400, + content=create_error_response( + message="No valid fields provided for update.", + code="NO_UPDATE_DATA" + ) ) try: @@ -797,15 +844,21 @@ async def update_movie( {"$set":update_dict} ) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"An error occurred while updating the movie: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"An error occurred while updating the movie: {str(e)}", + code="DATABASE_ERROR" + ) ) if result.matched_count == 0: - raise HTTPException( - status_code = 404, - detail=f"No movie with that _id was found: {movie_id}" + return JSONResponse( + status_code=404, + content=create_error_response( + message=f"No movie with that _id was found: {movie_id}", + code="MOVIE_NOT_FOUND" + ) ) updatedMovie = await movies_collection.find_one({"_id": movie_id}) @@ -842,9 +895,12 @@ async def update_movies_batch( update_data = request_body.get("update", {}) if not filter_data or not update_data: - raise HTTPException( - status_code = 400, - detail="Both filter and update objects are required" + return JSONResponse( + status_code=400, + content=create_error_response( + message="Both filter and update objects are required", + code="MISSING_FILTER" + ) ) # Convert string IDs to ObjectIds if _id filter is present @@ -854,17 +910,23 @@ async def update_movies_batch( try: filter_data["_id"]["$in"] = [ObjectId(id_str) for id_str in filter_data["_id"]["$in"]] except Exception: - raise HTTPException( - status_code = 400, - detail="Invalid ObjectId format in filter", + return JSONResponse( + status_code=400, + content=create_error_response( + message="Invalid ObjectId format in filter", + code="INVALID_OBJECT_ID" + ) ) try: result = await movies_collection.update_many(filter_data, {"$set": update_data}) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"An error occurred while updating movies: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"An error occurred while updating movies: {str(e)}", + code="DATABASE_ERROR" + ) ) return create_success_response({ @@ -894,9 +956,12 @@ async def delete_movie_by_id(id: str): try: object_id = ObjectId(id) except errors.InvalidId: - raise HTTPException( - status_code = 400, - detail=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId" + return JSONResponse( + status_code=400, + content=create_error_response( + message=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId", + code="INVALID_OBJECT_ID" + ) ) movies_collection = get_collection("movies") @@ -904,15 +969,21 @@ async def delete_movie_by_id(id: str): # Use deleteOne() to remove a single document result = await movies_collection.delete_one({"_id": object_id}) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred: {str(e)}", + code="DATABASE_ERROR" + ) ) if result.deleted_count == 0: - raise HTTPException( - status_code = 404, - detail=f"No movie found with ID: {id}" + return JSONResponse( + status_code=404, + content=create_error_response( + message=f"No movie found with ID: {id}", + code="MOVIE_NOT_FOUND" + ) ) return create_success_response( @@ -947,9 +1018,12 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse filter_data = request_body.get("filter", {}) if not filter_data: - raise HTTPException( - status_code = 400, - detail="Filter object is required and cannot be empty." + return JSONResponse( + status_code=400, + content=create_error_response( + message="Filter object is required and cannot be empty.", + code="MISSING_FILTER" + ) ) # Convert string IDs to ObjectIds if _id filter is present @@ -959,17 +1033,23 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse try: filter_data["_id"]["$in"] = [ObjectId(id_str) for id_str in filter_data["_id"]["$in"]] except Exception: - raise HTTPException( - status_code = 400, - detail="Invalid ObjectId format in filter." + return JSONResponse( + status_code=400, + content=create_error_response( + message="Invalid ObjectId format in filter.", + code="INVALID_OBJECT_ID" + ) ) try: result = await movies_collection.delete_many(filter_data) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"An error occurred while deleting movies: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"An error occurred while deleting movies: {str(e)}", + code="DATABASE_ERROR" + ) ) return create_success_response( @@ -998,9 +1078,12 @@ async def find_and_delete_movie(id: str): try: object_id = ObjectId(id) except errors.InvalidId: - raise HTTPException( - status_code = 400, - detail=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId" + return JSONResponse( + status_code=400, + content=create_error_response( + message=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId", + code="INVALID_OBJECT_ID" + ) ) movies_collection = get_collection("movies") @@ -1010,15 +1093,21 @@ async def find_and_delete_movie(id: str): try: deleted_movie = await movies_collection.find_one_and_delete({"_id": object_id}) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred: {str(e)}", + code="DATABASE_ERROR" + ) ) if deleted_movie is None: - raise HTTPException( - status_code = 404, - detail=f"No movie found with ID: {id}" + return JSONResponse( + status_code=404, + content=create_error_response( + message=f"No movie found with ID: {id}", + code="MOVIE_NOT_FOUND" + ) ) deleted_movie["_id"] = str(deleted_movie["_id"]) # Convert ObjectId to string @@ -1071,9 +1160,12 @@ async def aggregate_movies_recent_commented( object_id = ObjectId(movie_id) pipeline[0]["$match"]["_id"] = object_id except Exception: - raise HTTPException( - status_code = 400, - detail="The provided movie_id is not a valid ObjectId" + return JSONResponse( + status_code=400, + content=create_error_response( + message="The provided movie_id is not a valid ObjectId", + code="INVALID_OBJECT_ID" + ) ) # Add remaining pipeline stages @@ -1160,9 +1252,12 @@ async def aggregate_movies_recent_commented( try: results = await execute_aggregation(pipeline) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred during aggregation: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred during aggregation: {str(e)}", + code="DATABASE_ERROR" + ) ) # Convert ObjectId to string for response @@ -1293,9 +1388,12 @@ async def aggregate_movies_by_year(): try: results = await execute_aggregation(pipeline) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred during aggregation: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred during aggregation: {str(e)}", + code="DATABASE_ERROR" + ) ) return create_success_response( @@ -1394,9 +1492,12 @@ async def aggregate_directors_most_movies( try: results = await execute_aggregation(pipeline) except Exception as e: - raise HTTPException( - status_code = 500, - detail=f"Database error occurred during aggregation: {str(e)}" + return JSONResponse( + status_code=500, + content=create_error_response( + message=f"Database error occurred during aggregation: {str(e)}", + code="DATABASE_ERROR" + ) ) return create_success_response( diff --git a/mflix/server/python-fastapi/src/utils/response_docs.py b/mflix/server/python-fastapi/src/utils/response_docs.py index c9a4531..7ce5135 100644 --- a/mflix/server/python-fastapi/src/utils/response_docs.py +++ b/mflix/server/python-fastapi/src/utils/response_docs.py @@ -3,205 +3,222 @@ This module provides reusable response documentation for FastAPI endpoints to maintain consistent OpenAPI documentation across all movie API endpoints. -Supports both FastAPI standard format and custom application error format. +Uses the standardized error format from create_error_response() to match Express backend. """ -# FastAPI Standard Error Responses (HTTPException format) -FASTAPI_400_INVALID_OBJECTID = { - "description": "Bad Request - Invalid ObjectId format", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "detail": {"type": "string"} - }, - "example": { - "detail": "The provided ID 'invalid_id' is not a valid ObjectId" - } + +# Helper schema for standardized error responses (matches create_error_response format) +ERROR_RESPONSE_SCHEMA = { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "error": { + "type": "object", + "properties": { + "message": {"type": "string"}, + "code": {"type": "string"}, + "details": {"type": "string"} } - } + }, + "timestamp": {"type": "string"} } } -FASTAPI_400_INVALID_SEARCH_OPERATOR = { - "description": "Bad Request - Invalid search operator", + +# 400 Bad Request Responses +ERROR_400_INVALID_OBJECTID = { + "description": "Bad Request - Invalid ObjectId format", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "detail": {"type": "string"} - }, + **ERROR_RESPONSE_SCHEMA, "example": { - "detail": "Invalid search operator 'invalid'. The search operator must be one of {'must', 'should', 'mustNot', 'filter'}." + "success": False, + "message": "The provided ID 'invalid_id' is not a valid ObjectId", + "error": { + "message": "The provided ID 'invalid_id' is not a valid ObjectId", + "code": "INVALID_OBJECT_ID", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" } } } } } -FASTAPI_400_VALIDATION_ERROR = { +ERROR_400_VALIDATION = { "description": "Bad Request - Request validation failed", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "detail": {"type": "string"} - }, + **ERROR_RESPONSE_SCHEMA, "example": { - "detail": "Invalid request body format" + "success": False, + "message": "No valid fields provided for update.", + "error": { + "message": "No valid fields provided for update.", + "code": "NO_UPDATE_DATA", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" } } } } } -FASTAPI_422_VALIDATION_ERROR = { - "description": "Unprocessable Entity - Validation error", +# Combined 400 response for search endpoints (covers both invalid operator and missing params) +ERROR_400_SEARCH_ERRORS = { + "description": "Bad Request - Invalid search operator or missing search parameters", "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "detail": { - "type": "array", - "items": { - "type": "object", - "properties": { - "loc": {"type": "array"}, - "msg": {"type": "string"}, - "type": {"type": "string"} - } - } + "schema": ERROR_RESPONSE_SCHEMA, + "examples": { + "invalid_operator": { + "summary": "Invalid search operator", + "value": { + "success": False, + "message": "Invalid search operator 'invalid'. The search operator must be one of {'must', 'should', 'mustNot', 'filter'}.", + "error": { + "message": "Invalid search operator 'invalid'. The search operator must be one of {'must', 'should', 'mustNot', 'filter'}.", + "code": "INVALID_SEARCH_OPERATOR", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" } }, - "example": { - "detail": [ - { - "loc": ["body", "title"], - "msg": "field required", - "type": "value_error.missing" - } - ] + "missing_params": { + "summary": "Missing search parameters", + "value": { + "success": False, + "message": "At least one search parameter must be provided.", + "error": { + "message": "At least one search parameter must be provided.", + "code": "MISSING_SEARCH_PARAMS", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" + } } } } } } -FASTAPI_404_MOVIE_NOT_FOUND = { - "description": "Not Found - Movie not found", +# 401 Unauthorized Responses +ERROR_401_VOYAGE_AUTH = { + "description": "Unauthorized - Invalid Voyage AI API key", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "detail": {"type": "string"} - }, + **ERROR_RESPONSE_SCHEMA, "example": { - "detail": "No movie found with ID: 507f1f77bcf86cd799439011" + "success": False, + "message": "Invalid Voyage AI API key", + "error": { + "message": "Invalid Voyage AI API key", + "code": "VOYAGE_AUTH_ERROR", + "details": "Please verify your VOYAGE_API_KEY is correct in the .env file" + }, + "timestamp": "2024-01-01T12:00:00.000Z" } } } } } -FASTAPI_500_DATABASE_ERROR = { - "description": "Internal Server Error - Database operation failed", +# 404 Not Found Responses +ERROR_404_MOVIE_NOT_FOUND = { + "description": "Not Found - Movie not found", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "detail": {"type": "string"} - }, + **ERROR_RESPONSE_SCHEMA, "example": { - "detail": "Database error occurred: Connection timeout" + "success": False, + "message": "No movie found with ID: 507f1f77bcf86cd799439011", + "error": { + "message": "No movie found with ID: 507f1f77bcf86cd799439011", + "code": "MOVIE_NOT_FOUND", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" } } } } } -FASTAPI_500_SEARCH_ERROR = { - "description": "Internal Server Error - Search operation failed", +# 422 Unprocessable Entity - FastAPI's auto-generated validation errors +FASTAPI_422_VALIDATION_ERROR = { + "description": "Unprocessable Entity - Validation error", "content": { "application/json": { "schema": { "type": "object", "properties": { - "detail": {"type": "string"} - }, - "example": { - "detail": "An error occurred while performing the search: Index not found" - } - } - } - } -} - -FASTAPI_500_VECTOR_SEARCH_ERROR = { - "description": "Internal Server Error - Vector search operation failed", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "detail": {"type": "string"} + "detail": { + "type": "array", + "items": { + "type": "object", + "properties": { + "loc": {"type": "array"}, + "msg": {"type": "string"}, + "type": {"type": "string"} + } + } + } }, "example": { - "detail": "Error performing vector search: Embedding generation failed" + "detail": [ + { + "loc": ["body", "title"], + "msg": "field required", + "type": "value_error.missing" + } + ] } } } } } -FASTAPI_400_MISSING_SEARCH_PARAMS = { - "description": "Bad Request - Missing search parameters", +# 500 Internal Server Error Responses +ERROR_500_DATABASE = { + "description": "Internal Server Error - Database operation failed", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "detail": {"type": "string"} - }, + **ERROR_RESPONSE_SCHEMA, "example": { - "detail": "At least one search parameter must be provided." + "success": False, + "message": "Database error occurred: Connection timeout", + "error": { + "message": "Database error occurred: Connection timeout", + "code": "DATABASE_ERROR", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" } } } } } -# Custom Application Error Responses (create_error_response format) -CUSTOM_400_VOYAGE_SERVICE_UNAVAILABLE = { - "description": "Bad Request - Vector search service unavailable", +ERROR_500_SEARCH = { + "description": "Internal Server Error - Search operation failed", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": {"type": "boolean"}, - "message": {"type": "string"}, - "error": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "code": {"type": "string"}, - "details": {"type": "string"} - } - }, - "timestamp": {"type": "string"} - }, + **ERROR_RESPONSE_SCHEMA, "example": { "success": False, - "message": "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + "message": "An error occurred while performing the search: Index not found", "error": { - "message": "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", - "code": "SERVICE_UNAVAILABLE", + "message": "An error occurred while performing the search: Index not found", + "code": "SEARCH_ERROR", "details": None }, "timestamp": "2024-01-01T12:00:00.000Z" @@ -211,32 +228,19 @@ } } -CUSTOM_401_VOYAGE_AUTH_ERROR = { - "description": "Unauthorized - Invalid Voyage AI API key", +ERROR_500_VECTOR_SEARCH = { + "description": "Internal Server Error - Vector search operation failed", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": {"type": "boolean"}, - "message": {"type": "string"}, - "error": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "code": {"type": "string"}, - "details": {"type": "string"} - } - }, - "timestamp": {"type": "string"} - }, + **ERROR_RESPONSE_SCHEMA, "example": { "success": False, - "message": "Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file", + "message": "Error performing vector search: Embedding generation failed", "error": { - "message": "Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file", - "code": "VOYAGE_AUTH_ERROR", - "details": "Please verify your VOYAGE_API_KEY is correct in the .env file" + "message": "Error performing vector search: Embedding generation failed", + "code": "VECTOR_SEARCH_ERROR", + "details": None }, "timestamp": "2024-01-01T12:00:00.000Z" } @@ -245,67 +249,71 @@ } } -CUSTOM_503_VOYAGE_API_ERROR = { - "description": "Service Unavailable - Voyage AI API error", +# 503 Service Unavailable Responses +ERROR_503_VOYAGE = { + "description": "Service Unavailable - Vector search service unavailable", "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "success": {"type": "boolean"}, - "message": {"type": "string"}, - "error": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "code": {"type": "string"}, - "details": {"type": "string"} - } - }, - "timestamp": {"type": "string"} + "schema": ERROR_RESPONSE_SCHEMA, + "examples": { + "api_key_not_configured": { + "summary": "Voyage API key not configured", + "value": { + "success": False, + "message": "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + "error": { + "message": "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + "code": "SERVICE_UNAVAILABLE", + "details": None + }, + "timestamp": "2024-01-01T12:00:00.000Z" + } }, - "example": { - "success": False, - "message": "Vector search service unavailable", - "error": { - "message": "Voyage AI API returned status 503: Service temporarily unavailable", - "code": "VOYAGE_API_ERROR", - "details": "Voyage AI API returned status 503: Service temporarily unavailable" - }, - "timestamp": "2024-01-01T12:00:00.000Z" + "voyage_api_error": { + "summary": "Voyage AI API error", + "value": { + "success": False, + "message": "Vector search service unavailable", + "error": { + "message": "Vector search service unavailable", + "code": "VOYAGE_API_ERROR", + "details": "Voyage AI service temporarily unavailable" + }, + "timestamp": "2024-01-01T12:00:00.000Z" + } } } } } } + # Common response combinations for different endpoint types OBJECTID_VALIDATION_RESPONSES = { - 400: FASTAPI_400_INVALID_OBJECTID, - 404: FASTAPI_404_MOVIE_NOT_FOUND, - 500: FASTAPI_500_DATABASE_ERROR + 400: ERROR_400_INVALID_OBJECTID, + 404: ERROR_404_MOVIE_NOT_FOUND, + 500: ERROR_500_DATABASE } SEARCH_ENDPOINT_RESPONSES = { - 400: FASTAPI_400_INVALID_SEARCH_OPERATOR, - 500: FASTAPI_500_SEARCH_ERROR + 400: ERROR_400_SEARCH_ERRORS, + 500: ERROR_500_SEARCH } VECTOR_SEARCH_RESPONSES = { - 400: CUSTOM_400_VOYAGE_SERVICE_UNAVAILABLE, - 401: CUSTOM_401_VOYAGE_AUTH_ERROR, - 500: FASTAPI_500_VECTOR_SEARCH_ERROR, - 503: CUSTOM_503_VOYAGE_API_ERROR + 401: ERROR_401_VOYAGE_AUTH, + 500: ERROR_500_VECTOR_SEARCH, + 503: ERROR_503_VOYAGE } DATABASE_OPERATION_RESPONSES = { - 500: FASTAPI_500_DATABASE_ERROR + 500: ERROR_500_DATABASE } CRUD_OPERATION_RESPONSES = { - 400: FASTAPI_400_VALIDATION_ERROR, + 400: ERROR_400_VALIDATION, 422: FASTAPI_422_VALIDATION_ERROR, - 500: FASTAPI_500_DATABASE_ERROR + 500: ERROR_500_DATABASE } CRUD_WITH_OBJECTID_RESPONSES = { From 4d16f4d72dbdef5675973acf0c8bfce1ac5c0030 Mon Sep 17 00:00:00 2001 From: Taylor McNeil Date: Mon, 23 Feb 2026 15:33:07 -0500 Subject: [PATCH 3/3] updating tests to match new response formats --- .../test_movie_routes_integration.py | 9 +- .../python-fastapi/tests/test_movie_routes.py | 267 +++++++++++------- 2 files changed, 163 insertions(+), 113 deletions(-) diff --git a/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py b/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py index c9c9730..30cc6c6 100644 --- a/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py +++ b/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py @@ -126,8 +126,8 @@ async def test_delete_movie(self, client, test_movie_data): get_response = await client.get(f"/api/movies/{movie_id}") assert get_response.status_code == 404 error_data = get_response.json() - assert "detail" in error_data - assert "no movie found" in error_data["detail"].lower() + assert error_data["success"] is False + assert error_data["error"]["code"] == "MOVIE_NOT_FOUND" # No cleanup needed - movie already deleted @@ -276,13 +276,12 @@ async def test_batch_delete_movies(self, client, multiple_test_movies): assert delete_data["data"]["deletedCount"] == 3 # Verify all movies were deleted - # Note: The API returns 200 with INTERNAL_SERVER_ERROR code, not 404 for movie_id in multiple_test_movies: get_response = await client.get(f"/api/movies/{movie_id}") assert get_response.status_code == 404 error_data = get_response.json() - assert "detail" in error_data - assert "no movie found" in error_data["detail"].lower() + assert error_data["success"] is False + assert error_data["error"]["code"] == "MOVIE_NOT_FOUND" # Note: Fixture cleanup will try to delete but movies are already gone # The fixture should handle this gracefully diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index 8bb335d..b9f12f8 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -6,10 +6,11 @@ an actual database connection or server instance. """ +import json import pytest from unittest.mock import AsyncMock, MagicMock, patch from bson import ObjectId -from fastapi import HTTPException +from fastapi.responses import JSONResponse from src.models.models import CreateMovieRequest, UpdateMovieRequest from src.utils.exceptions import VoyageAuthError, VoyageAPIError @@ -59,23 +60,27 @@ async def test_get_movie_by_id_not_found(self, mock_get_collection): # Import and call the route handler from src.routers.movies import get_movie_by_id - with pytest.raises(HTTPException) as e: - await get_movie_by_id(TEST_MOVIE_ID) + response = await get_movie_by_id(TEST_MOVIE_ID) # Assertions - assert e.value.status_code == 404 - assert "no movie found" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MOVIE_NOT_FOUND" async def test_get_movie_by_id_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" # Import and call the route handler from src.routers.movies import get_movie_by_id - with pytest.raises(HTTPException) as e: - await get_movie_by_id(INVALID_MOVIE_ID) + response = await get_movie_by_id(INVALID_MOVIE_ID) # Assertions - assert e.value.status_code == 400 - assert " not a valid" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "INVALID_OBJECT_ID" @patch('src.routers.movies.get_collection') async def test_get_movie_by_id_database_error(self, mock_get_collection): @@ -87,12 +92,14 @@ async def test_get_movie_by_id_database_error(self, mock_get_collection): # Import and call the route handler from src.routers.movies import get_movie_by_id - with pytest.raises(HTTPException) as e: - await get_movie_by_id(TEST_MOVIE_ID) + response = await get_movie_by_id(TEST_MOVIE_ID) # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR" @pytest.mark.unit @@ -145,12 +152,14 @@ async def test_create_movie_database_error(self, mock_get_collection): # Create request from src.routers.movies import create_movie movie_request = CreateMovieRequest(title="New Movie") - with pytest.raises(HTTPException) as e: - await create_movie(movie_request) + response = await create_movie(movie_request) # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR" @pytest.mark.unit @@ -201,12 +210,14 @@ async def test_update_movie_not_found(self, mock_get_collection): from src.routers.movies import update_movie update_request = UpdateMovieRequest(title="Updated Movie") - with pytest.raises(HTTPException) as e: - await update_movie(update_request, TEST_MOVIE_ID) - - #Assertions - assert e.value.status_code == 404 - assert "no movie" in str(e.value.detail.lower()) + response = await update_movie(update_request, TEST_MOVIE_ID) + + # Assertions + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MOVIE_NOT_FOUND" async def test_update_movie_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" @@ -214,12 +225,14 @@ async def test_update_movie_invalid_id(self): from src.routers.movies import update_movie update_request = UpdateMovieRequest(title="Updated Movie") - with pytest.raises(HTTPException) as e: - await update_movie(update_request, INVALID_MOVIE_ID) + response = await update_movie(update_request, INVALID_MOVIE_ID) - # Assertions - assert e.value.status_code == 400 - assert "invalid" in str(e.value.detail.lower()) + # Assertions + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "INVALID_OBJECT_ID" @pytest.mark.unit @@ -258,23 +271,27 @@ async def test_delete_movie_not_found(self, mock_get_collection): # Call the route handler from src.routers.movies import delete_movie_by_id - with pytest.raises(HTTPException) as e: - await delete_movie_by_id(TEST_MOVIE_ID) + response = await delete_movie_by_id(TEST_MOVIE_ID) - # Assertions - assert e.value.status_code == 404 - assert "no movie" in str(e.value.detail.lower()) + # Assertions + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MOVIE_NOT_FOUND" async def test_delete_movie_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" # Call the route handler from src.routers.movies import delete_movie_by_id - with pytest.raises(HTTPException) as e: - await delete_movie_by_id(INVALID_MOVIE_ID) + response = await delete_movie_by_id(INVALID_MOVIE_ID) # Assertions - assert e.value.status_code == 400 - assert "invalid movie id" in str(e.value.detail.lower()) + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "INVALID_OBJECT_ID" @patch('src.routers.movies.get_collection') async def test_delete_movie_database_error(self, mock_get_collection): @@ -286,12 +303,14 @@ async def test_delete_movie_database_error(self, mock_get_collection): # Call the route handler from src.routers.movies import delete_movie_by_id - with pytest.raises(HTTPException) as e: - await delete_movie_by_id(TEST_MOVIE_ID) + response = await delete_movie_by_id(TEST_MOVIE_ID) - # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail.lower()) + # Assertions + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR" @pytest.mark.unit @pytest.mark.asyncio @@ -394,12 +413,14 @@ async def test_get_all_movies_database_error(self, mock_get_collection): # Call the route handler from src.routers.movies import get_all_movies - with pytest.raises(HTTPException) as e: - await get_all_movies() + response = await get_all_movies() # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail.lower()) + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR" @pytest.mark.unit @@ -441,12 +462,14 @@ async def test_create_movies_batch_empty_list(self, mock_get_collection): # Create request with empty list from src.routers.movies import create_movies_batch - with pytest.raises(HTTPException) as e: - await create_movies_batch([]) + response = await create_movies_batch([]) # Assertions - assert e.value.status_code == 400 - assert "empty" in str(e.value.detail.lower()) + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "EMPTY_REQUEST" @patch('src.routers.movies.get_collection') async def test_delete_movies_batch_success(self, mock_get_collection): @@ -476,12 +499,14 @@ async def test_delete_movies_batch_missing_filter(self, mock_get_collection): # Create request without filter from src.routers.movies import delete_movies_batch request_body = {} - with pytest.raises(HTTPException) as e: - await delete_movies_batch(request_body) + response = await delete_movies_batch(request_body) # Assertions - assert e.value.status_code == 400 - assert "filter" in e.value.detail.lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MISSING_FILTER" @@ -523,23 +548,27 @@ async def test_find_and_delete_not_found(self, mock_get_collection): # Call the route handler from src.routers.movies import find_and_delete_movie - with pytest.raises(HTTPException) as e: - await find_and_delete_movie(TEST_MOVIE_ID) + response = await find_and_delete_movie(TEST_MOVIE_ID) # Assertions - assert e.value.status_code == 404 - assert "no movie" in str(e.value.detail.lower()) + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MOVIE_NOT_FOUND" async def test_find_and_delete_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" # Call the route handler from src.routers.movies import find_and_delete_movie - with pytest.raises(HTTPException) as e: - await find_and_delete_movie(INVALID_MOVIE_ID) + response = await find_and_delete_movie(INVALID_MOVIE_ID) # Assertions - assert e.value.status_code == 400 - assert "invalid" in str(e.value.detail.lower()) + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "INVALID_OBJECT_ID" @pytest.mark.unit @@ -580,12 +609,14 @@ async def test_update_movies_batch_missing_filter(self, mock_get_collection): # Create request without filter from src.routers.movies import update_movies_batch request_body = {"update": {"$set": {"rated": "PG-13"}}} - with pytest.raises(HTTPException) as e: - await update_movies_batch(request_body) + response = await update_movies_batch(request_body) # Assertions - assert e.value.status_code == 400 - assert "filter" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MISSING_FILTER" @patch('src.routers.movies.get_collection') async def test_update_movies_batch_missing_update(self, mock_get_collection): @@ -595,12 +626,14 @@ async def test_update_movies_batch_missing_update(self, mock_get_collection): # Create request without update from src.routers.movies import update_movies_batch request_body = {"filter": {"year": 2020}} - with pytest.raises(HTTPException) as e: - await update_movies_batch(request_body) + response = await update_movies_batch(request_body) - # Assertions - assert e.value.status_code == 400 - assert "update" in str(e.value.detail).lower() + # Assertions - code returns MISSING_FILTER for both missing filter and missing update + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MISSING_FILTER" @patch('src.routers.movies.get_collection') async def test_update_movies_batch_no_matches(self, mock_get_collection): @@ -700,22 +733,26 @@ async def test_search_movies_with_pagination(self, mock_execute_aggregation): async def test_search_movies_no_parameters(self): """Should return error when no search parameters provided.""" from src.routers.movies import search_movies - with pytest.raises(HTTPException) as e: - await search_movies(search_operator="must") + response = await search_movies(search_operator="must") # Assertions - assert e.value.status_code == 400 - assert "one search parameter" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "MISSING_SEARCH_PARAMS" async def test_search_movies_invalid_operator(self): """Should return error for invalid search operator.""" from src.routers.movies import search_movies - with pytest.raises(HTTPException) as e: - await search_movies(plot="test", search_operator="invalid") + response = await search_movies(plot="test", search_operator="invalid") # Assertions - assert e.value.status_code == 400 - assert "invalid search operator" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "INVALID_SEARCH_OPERATOR" @patch('src.routers.movies.execute_aggregation') async def test_search_movies_database_error(self, mock_execute_aggregation): @@ -725,12 +762,14 @@ async def test_search_movies_database_error(self, mock_execute_aggregation): # Call the route handler from src.routers.movies import search_movies - with pytest.raises(HTTPException) as e: - await search_movies(plot="test", search_operator="must") + response = await search_movies(plot="test", search_operator="must") # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "SEARCH_ERROR" @patch('src.routers.movies.execute_aggregation') async def test_search_movies_empty_results(self, mock_execute_aggregation): @@ -770,7 +809,7 @@ async def test_vector_search_unavailable(self, mock_voyage_available): # Assertions assert isinstance(response, JSONResponse) - assert response.status_code == 400 + assert response.status_code == 503 # Parse the response body import json @@ -827,12 +866,14 @@ async def test_vector_search_embedding_error(self, mock_get_embedding, mock_voya # Call the route handler from src.routers.movies import vector_search_movies - with pytest.raises(HTTPException) as e: - await vector_search_movies(q="action movie") + response = await vector_search_movies(q="action movie") # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "VECTOR_SEARCH_ERROR" @patch('src.routers.movies.voyage_ai_available') @patch('src.routers.movies.voyageai.Client') @@ -926,12 +967,14 @@ async def test_aggregate_movies_by_movie_id(self, mock_execute_aggregation): async def test_aggregate_movies_invalid_movie_id(self): """Should return error for invalid movie ID format.""" from src.routers.movies import aggregate_movies_recent_commented - with pytest.raises(HTTPException) as e: - await aggregate_movies_recent_commented(movie_id="invalid_id") + response = await aggregate_movies_recent_commented(movie_id="invalid_id") # Assertions - assert e.value.status_code == 400 - assert "movie_id is not" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "INVALID_OBJECT_ID" @patch('src.routers.movies.execute_aggregation') async def test_aggregate_movies_database_error(self, mock_execute_aggregation): @@ -941,12 +984,14 @@ async def test_aggregate_movies_database_error(self, mock_execute_aggregation): # Call the route handler from src.routers.movies import aggregate_movies_recent_commented - with pytest.raises(HTTPException) as e: - await aggregate_movies_recent_commented(limit=10, movie_id=None) + response = await aggregate_movies_recent_commented(limit=10, movie_id=None) # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR" @patch('src.routers.movies.execute_aggregation') async def test_aggregate_movies_empty_results(self, mock_execute_aggregation): @@ -997,12 +1042,14 @@ async def test_aggregate_movies_by_year_database_error(self, mock_execute_aggreg # Call the route handler from src.routers.movies import aggregate_movies_by_year - with pytest.raises(HTTPException) as e: - await aggregate_movies_by_year() + response = await aggregate_movies_by_year() # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR" @patch('src.routers.movies.execute_aggregation') async def test_aggregate_movies_by_year_empty_results(self, mock_execute_aggregation): @@ -1071,12 +1118,14 @@ async def test_aggregate_directors_database_error(self, mock_execute_aggregation # Call the route handler from src.routers.movies import aggregate_directors_most_movies - with pytest.raises(HTTPException) as e: - await aggregate_directors_most_movies() + response = await aggregate_directors_most_movies() # Assertions - assert e.value.status_code == 500 - assert "error" in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR" @patch('src.routers.movies.execute_aggregation') async def test_aggregate_directors_empty_results(self, mock_execute_aggregation): @@ -1164,9 +1213,11 @@ async def test_get_distinct_genres_database_error(self, mock_get_collection): # Call the route handler from src.routers.movies import get_distinct_genres - with pytest.raises(HTTPException) as exc_info: - await get_distinct_genres() + response = await get_distinct_genres() # Assertions - assert exc_info.value.status_code == 500 - assert "Database error" in str(exc_info.value.detail) + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "DATABASE_ERROR"