diff --git a/mflix/client/app/aggregations/page.tsx b/mflix/client/app/aggregations/page.tsx index a211141..00b8d16 100644 --- a/mflix/client/app/aggregations/page.tsx +++ b/mflix/client/app/aggregations/page.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { fetchMoviesWithComments, fetchMoviesByYear, fetchDirectorStats } from '../lib/api'; -import { MovieWithComments, YearlyStats, DirectorStats } from '../types/aggregations'; +import { fetchMoviesWithComments, fetchMoviesByYear, fetchDirectorStats } from '@/lib/api'; +import { MovieWithComments, YearlyStats, DirectorStats } from '@/types/aggregations'; import styles from './aggregations.module.css'; export default async function AggregationsPage() { diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts index 9c77b47..2d20442 100644 --- a/mflix/client/app/lib/api.ts +++ b/mflix/client/app/lib/api.ts @@ -680,16 +680,36 @@ export async function vectorSearchMovies(searchParams: { const result = await response.json(); if (!response.ok) { - return { - success: false, - error: result.error || `Failed to perform vector search: ${response.status}` + // Extract error message from the standardized error response + const errorMessage = result.message || result.error?.message || `Failed to perform vector search: ${response.status}`; + const errorCode = result.error?.code; + + // Provide user-friendly messages for specific error codes + if (errorCode === 'VOYAGE_AUTH_ERROR') { + return { + success: false, + error: 'Vector search unavailable: Your Voyage AI API key is missing or invalid. Please add a valid VOYAGE_API_KEY to your .env file and restart the server.' + }; + } + + if (errorCode === 'SERVICE_UNAVAILABLE' || errorCode === 'VOYAGE_API_ERROR') { + return { + success: false, + error: errorMessage || 'Vector search service is currently unavailable. Please try again later.' + }; + } + + return { + success: false, + error: errorMessage }; } if (!result.success) { - return { - success: false, - error: result.error || 'API returned error response' + const errorMessage = result.message || result.error?.message || 'API returned error response'; + return { + success: false, + error: errorMessage }; } diff --git a/mflix/client/app/movie/[id]/error.tsx b/mflix/client/app/movie/[id]/error.tsx index 2e76a32..300904b 100644 --- a/mflix/client/app/movie/[id]/error.tsx +++ b/mflix/client/app/movie/[id]/error.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import Link from 'next/link'; -import { ROUTES } from '../../lib/constants'; +import { ROUTES } from '@/lib/constants'; import styles from './error.module.css'; export default function MovieDetailsError({ diff --git a/mflix/client/app/movie/[id]/loading.tsx b/mflix/client/app/movie/[id]/loading.tsx index dace3cf..89e004e 100644 --- a/mflix/client/app/movie/[id]/loading.tsx +++ b/mflix/client/app/movie/[id]/loading.tsx @@ -1,5 +1,5 @@ import pageStyles from './page.module.css'; -import { MovieDetailsSkeleton } from '../../components/LoadingSkeleton'; +import { MovieDetailsSkeleton } from '@/components'; export default function MovieDetailsLoading() { return ( diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java index aa6e544..3776dae 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java @@ -79,6 +79,61 @@ public ResponseEntity handleMissingServletRequestParameter( return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); } + @ExceptionHandler(ServiceUnavailableException.class) + public ResponseEntity handleServiceUnavailableException( + ServiceUnavailableException ex, WebRequest request) { + logger.error("Service unavailable: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage()) + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("SERVICE_UNAVAILABLE") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(VoyageAuthException.class) + public ResponseEntity handleVoyageAuthException( + VoyageAuthException ex, WebRequest request) { + logger.error("Voyage AI authentication error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage()) + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("VOYAGE_AUTH_ERROR") + .details("Please verify your VOYAGE_API_KEY is correct in the .env file") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(VoyageAPIException.class) + public ResponseEntity handleVoyageAPIException( + VoyageAPIException ex, WebRequest request) { + logger.error("Voyage AI API error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message("Vector search service unavailable") + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("VOYAGE_API_ERROR") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.SERVICE_UNAVAILABLE); + } + @ExceptionHandler(DatabaseOperationException.class) public ResponseEntity handleDatabaseOperationException( DatabaseOperationException ex, WebRequest request) { diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..c515631 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java @@ -0,0 +1,17 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when a required service is unavailable or not configured. + * + * This exception results in a 400 Bad Request response with SERVICE_UNAVAILABLE code. + * Typically occurs when: + * - A required API key is not configured + * - A required service is not available + */ +public class ServiceUnavailableException extends RuntimeException { + + public ServiceUnavailableException(String message) { + super(message); + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java new file mode 100644 index 0000000..6279a5c --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java @@ -0,0 +1,29 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when Voyage AI API returns an error. + * + * This exception results in a 503 Service Unavailable response. + * Typically occurs when: + * - The Voyage AI API is down or unavailable + * - The API returns an error response + * - Network issues prevent communication with the API + */ +public class VoyageAPIException extends RuntimeException { + + private final int statusCode; + + public VoyageAPIException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public VoyageAPIException(String message) { + this(message, 503); + } + + public int getStatusCode() { + return statusCode; + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java new file mode 100644 index 0000000..2445db8 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java @@ -0,0 +1,18 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when Voyage AI API authentication fails. + * + * This exception results in a 401 Unauthorized response. + * Typically occurs when: + * - The API key is invalid + * - The API key is missing + * - The API key has expired + */ +public class VoyageAuthException extends RuntimeException { + + public VoyageAuthException(String message) { + super(message); + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java index f86bbb5..a0a8943 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java @@ -6,7 +6,10 @@ import com.mongodb.client.result.UpdateResult; import com.mongodb.samplemflix.exception.DatabaseOperationException; import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ServiceUnavailableException; import com.mongodb.samplemflix.exception.ValidationException; +import com.mongodb.samplemflix.exception.VoyageAPIException; +import com.mongodb.samplemflix.exception.VoyageAuthException; import com.mongodb.samplemflix.model.Movie; import com.mongodb.samplemflix.model.dto.*; import com.mongodb.samplemflix.repository.MovieRepository; @@ -821,8 +824,8 @@ public List vectorSearchMovies(String query, Integer limit) // Check if Voyage API key is configured if (voyageApiKey == null || voyageApiKey.trim().isEmpty() || voyageApiKey.equals("your_voyage_api_key")) { - throw new ValidationException( - "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your Voyage AI API key to the .env file" + throw new ServiceUnavailableException( + "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file" ); } @@ -929,10 +932,16 @@ public List vectorSearchMovies(String query, Integer limit) return results; + } catch (VoyageAuthException e) { + // Re-raise Voyage AI authentication errors to be handled by GlobalExceptionHandler + throw e; + } catch (VoyageAPIException e) { + // Re-raise Voyage AI API errors to be handled by GlobalExceptionHandler + throw e; } catch (IOException e) { - // Handle Voyage AI API errors + // Handle network errors calling Voyage AI API String errorMsg = e.getMessage() != null ? e.getMessage() : "Network error calling Voyage AI API"; - throw new DatabaseOperationException("Error performing vector search: " + errorMsg); + throw new VoyageAPIException("Error performing vector search: " + errorMsg); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new DatabaseOperationException("Vector search was interrupted"); @@ -981,9 +990,12 @@ private List generateVoyageEmbedding(String text, String apiKey) throws if (response.statusCode() != 200) { // Handle authentication errors specifically if (response.statusCode() == 401) { - throw new IOException("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file"); + throw new VoyageAuthException("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file"); } - throw new IOException("Voyage AI API returned status code " + response.statusCode() + ": " + response.body()); + throw new VoyageAPIException( + "Voyage AI API returned status code " + response.statusCode() + ": " + response.body(), + response.statusCode() + ); } // Parse the JSON response to extract the embedding diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java index 6773e0b..335e763 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java @@ -8,6 +8,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.result.UpdateResult; import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ServiceUnavailableException; import com.mongodb.samplemflix.exception.ValidationException; import com.mongodb.samplemflix.model.Movie; import com.mongodb.samplemflix.model.dto.BatchInsertResponse; @@ -736,23 +737,23 @@ void testVectorSearchMovies_EmptyQuery() { } @Test - @DisplayName("Should throw ValidationException when API key is missing in vector search") + @DisplayName("Should throw ServiceUnavailableException when API key is missing in vector search") void testVectorSearchMovies_MissingApiKey() { // Arrange ReflectionTestUtils.setField(movieService, "voyageApiKey", null); // Act & Assert - assertThrows(ValidationException.class, () -> movieService.vectorSearchMovies("test query", 10)); + assertThrows(ServiceUnavailableException.class, () -> movieService.vectorSearchMovies("test query", 10)); } @Test - @DisplayName("Should throw ValidationException when API key is placeholder value in vector search") + @DisplayName("Should throw ServiceUnavailableException when API key is placeholder value in vector search") void testVectorSearchMovies_PlaceholderApiKey() { // Arrange ReflectionTestUtils.setField(movieService, "voyageApiKey", "your_voyage_api_key"); // Act & Assert - assertThrows(ValidationException.class, () -> movieService.vectorSearchMovies("test query", 10)); + assertThrows(ServiceUnavailableException.class, () -> movieService.vectorSearchMovies("test query", 10)); } @Test diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index d913a09..86febc3 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -835,6 +835,36 @@ export async function vectorSearchMovies(req: Request, res: Response): Promise representing the embedding vector + * @throws VoyageAuthError if the API key is invalid (401) + * @throws VoyageAPIError for other API errors */ async function generateVoyageEmbedding(text: string, apiKey: string): Promise { // Build the request body with output_dimension set to 2048 @@ -1192,21 +1247,36 @@ async function generateVoyageEmbedding(text: string, apiKey: string): Promise { ); }); - it("should handle Voyage AI API errors", async () => { + it("should handle Voyage AI authentication errors with 401 status", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, @@ -891,11 +891,30 @@ describe("Movie Controller Tests", () => { await vectorSearchMovies(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(500); + expect(mockStatus).toHaveBeenCalledWith(401); expect(mockCreateErrorResponse).toHaveBeenCalledWith( - "Error performing vector search", - "VECTOR_SEARCH_ERROR", - expect.stringContaining("Voyage AI API returned status 401") + "Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file", + "VOYAGE_AUTH_ERROR", + "Please verify your VOYAGE_API_KEY is correct in the .env file" + ); + }); + + it("should handle other Voyage AI API errors with 503 status", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal Server Error"), + } as any); + + mockRequest.query = { q: "test" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(503); + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + "Vector search service unavailable", + "VOYAGE_API_ERROR", + expect.stringContaining("Voyage AI API returned status 500") ); }); diff --git a/mflix/server/python-fastapi/main.py b/mflix/server/python-fastapi/main.py index 6a770cb..99913ae 100644 --- a/mflix/server/python-fastapi/main.py +++ b/mflix/server/python-fastapi/main.py @@ -1,8 +1,11 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from src.routers import movies from src.database.mongo_client import db, get_collection +from src.utils.exceptions import VoyageAuthError, VoyageAPIError +from src.utils.errorResponse import create_error_response import os from dotenv import load_dotenv @@ -136,6 +139,31 @@ async def ensure_standard_index(): app = FastAPI(lifespan=lifespan) +# Add custom exception handlers +@app.exception_handler(VoyageAuthError) +async def voyage_auth_error_handler(request: Request, exc: VoyageAuthError): + """Handle Voyage AI authentication errors with 401 status.""" + return JSONResponse( + status_code=401, + content=create_error_response( + message=exc.message, + code="VOYAGE_AUTH_ERROR", + details="Please verify your VOYAGE_API_KEY is correct in the .env file" + ) + ) + +@app.exception_handler(VoyageAPIError) +async def voyage_api_error_handler(request: Request, exc: VoyageAPIError): + """Handle Voyage AI API errors with 503 status.""" + return JSONResponse( + status_code=503, + content=create_error_response( + message="Vector search service unavailable", + code="VOYAGE_API_ERROR", + details=exc.message + ) + ) + # Add CORS middleware cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",") app.add_middleware( diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index d8fa176..f15d6bc 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -1,12 +1,17 @@ from fastapi import APIRouter, Query, Path, Body, HTTPException +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 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.exceptions import VoyageAuthError, VoyageAPIError from bson import ObjectId, errors import re from bson.errors import InvalidId import voyageai +import voyageai.error as voyage_error +import os ''' @@ -316,18 +321,20 @@ async def vector_search_movies( Returns: SuccessResponse containing a list of movies with similarity scores """ + # Check if Voyage AI API key is configured if not voyage_ai_available(): - raise HTTPException( - status_code = 503, - detail="Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to your .env file." + return JSONResponse( + status_code=400, + 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" + ) ) - try: - # Initialize the client here to avoid import-time errors - vo = voyageai.Client() + try: # The vector search index was already created at startup time - # Generate embedding for the search query - query_embedding = get_embedding(q, input_type="query", client=vo) + # Generate embedding for the search query (client is created inside get_embedding) + query_embedding = get_embedding(q, input_type="query") # Get the embedded movies collection embedded_movies_collection = get_collection("embedded_movies") @@ -390,10 +397,20 @@ async def vector_search_movies( f"Found {len(results)} similar movies for query: '{q}'" ) + except VoyageAuthError: + # Re-raise custom exceptions to be handled by the exception handlers + raise + except VoyageAPIError: + # Re-raise custom exceptions to be handled by the exception handlers + raise except Exception as e: + # Log the error for debugging + print(f"Vector search error: {str(e)}") + + # Handle generic errors raise HTTPException( - status_code = 500, - detail=f"An error occurred during vector search: {str(e)}" + status_code=500, + detail=f"Error performing vector search: {str(e)}" ) """ @@ -1348,12 +1365,35 @@ def get_embedding(data, input_type = "document", client=None): Returns: Vector embeddings for the given input - """ - if client is None: - client = voyageai.Client() - embeddings = client.embed( - data, model = model, output_dimension = outputDimension, input_type = input_type - ).embeddings - return embeddings[0] + Raises: + VoyageAuthError: If the API key is invalid (401) + VoyageAPIError: For other API errors + """ + try: + if client is None: + client = voyageai.Client() + + embeddings = client.embed( + data, model = model, output_dimension = outputDimension, input_type = input_type + ).embeddings + return embeddings[0] + except voyage_error.AuthenticationError as e: + # Handle authentication errors (401) from Voyage AI SDK + raise VoyageAuthError("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file") + except voyage_error.InvalidRequestError as e: + # Handle invalid request errors (400) - often due to malformed API key + raise VoyageAPIError(f"Invalid request to Voyage AI API: {str(e)}", 400) + except voyage_error.RateLimitError as e: + # Handle rate limiting errors (429) + raise VoyageAPIError(f"Voyage AI API rate limit exceeded: {str(e)}", 429) + except voyage_error.ServiceUnavailableError as e: + # Handle service unavailable errors (502, 503, 504) + raise VoyageAPIError(f"Voyage AI service unavailable: {str(e)}", 503) + except voyage_error.VoyageError as e: + # Handle any other Voyage AI SDK errors + raise VoyageAPIError(f"Voyage AI API error: {str(e)}", getattr(e, 'http_status', 500) or 500) + except Exception as e: + # Handle unexpected errors + raise VoyageAPIError(f"Failed to generate embedding: {str(e)}", 500) diff --git a/mflix/server/python-fastapi/src/utils/errorResponse.py b/mflix/server/python-fastapi/src/utils/errorResponse.py new file mode 100644 index 0000000..d82e144 --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/errorResponse.py @@ -0,0 +1,38 @@ +""" +Utility functions for creating standardized error responses. + +This module provides functions to create consistent error response structures +that match the Express backend's error format. +""" + +from datetime import datetime, timezone +from typing import Optional, Any + + +def create_error_response( + message: str, + code: Optional[str] = None, + details: Optional[Any] = None +) -> dict: + """ + Creates a standardized error response. + + Args: + message: The error message to display + code: Optional error code (e.g., 'VOYAGE_AUTH_ERROR', 'VOYAGE_API_ERROR') + details: Optional additional error details + + Returns: + A dictionary containing the standardized error response + """ + return { + "success": False, + "message": message, + "error": { + "message": message, + "code": code, + "details": details + }, + "timestamp": datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') + } + diff --git a/mflix/server/python-fastapi/src/utils/exceptions.py b/mflix/server/python-fastapi/src/utils/exceptions.py new file mode 100644 index 0000000..7ef6467 --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/exceptions.py @@ -0,0 +1,36 @@ +""" +Custom exception classes for the FastAPI application. + +This module defines custom exceptions for handling specific error scenarios, +particularly for Voyage AI API interactions. +""" + +class VoyageAuthError(Exception): + """ + Exception raised when Voyage AI API authentication fails. + + This typically occurs when: + - The API key is invalid + - The API key is missing + - The API key has expired + """ + def __init__(self, message: str = "Invalid Voyage AI API key"): + self.message = message + super().__init__(self.message) + + +class VoyageAPIError(Exception): + """ + Exception raised when Voyage AI API returns an error. + + This covers general API errors such as: + - Rate limiting + - Service unavailability + - Invalid requests + - Server errors + """ + def __init__(self, message: str, status_code: int = 500): + self.message = message + self.status_code = status_code + super().__init__(self.message) + diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index ce4b3b6..5256fab 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -12,6 +12,7 @@ from fastapi import HTTPException from src.models.models import CreateMovieRequest, UpdateMovieRequest +from src.utils.exceptions import VoyageAuthError, VoyageAPIError # Test constants @@ -763,12 +764,20 @@ async def test_vector_search_unavailable(self, mock_voyage_available): # 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") + from fastapi.responses import JSONResponse + + response = await vector_search_movies(q="action movie") # Assertions - assert e.value.status_code == 503 - assert str("VOYAGE_API_KEY not configured").lower() in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + # Parse the response body + import json + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "SERVICE_UNAVAILABLE" + assert "VOYAGE_API_KEY not configured" in body["message"] @patch('src.routers.movies.voyage_ai_available')