handleInputChange('directors', e.target.value)}
- className={`${styles.input} ${errors.directors ? styles.inputError : ''}`}
- disabled={isLoading}
- placeholder="Director names"
- />
- {errors.directors &&
+
Search Options
+
+
+
+
+
+ {searchOperatorOptions.find(opt => opt.value === formData.searchOperator)?.description}
+
+
+
+
+
+ handleInputChange('limit', e.target.value)}
+ className={`${styles.input} ${errors.limit ? styles.inputError : ''}`}
+ disabled={isLoading}
+ min="1"
+ max="100"
+ />
+ {errors.limit && {errors.limit}}
+
-
- {/* Writers Search */}
-
-
- handleInputChange('writers', e.target.value)}
- className={`${styles.input} ${errors.writers ? styles.inputError : ''}`}
- disabled={isLoading}
- placeholder="Writer names"
- />
- {errors.writers && {errors.writers}}
-
-
- {/* Cast Search */}
+
+ >
+ ) : (
+ <>
+ {/* Vector Search Fields */}
+
+
Semantic Search
-
- {/* Limit */}
-
-
- {/* Search Operator */}
-
-
- Search Logic
-
-
-
- {searchOperatorOptions.find(opt => opt.value === formData.searchOperator)?.description}
-
-
- >
- ) : (
- <>
- {/* Vector Search Fields */}
-
-
- Search Query
-
-
-
- {/* Limit for Vector Search */}
-
-
- Max Results
-
- handleInputChange('limit', e.target.value)}
- className={`${styles.input} ${errors.limit ? styles.inputError : ''}`}
- disabled={isLoading}
- min="1"
- max="50"
- />
- {errors.limit && {errors.limit}}
-
- Vector search supports up to 50 results
-
-
>
)}
diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts
index 9c77b47..c99c08f 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
};
}
@@ -705,6 +725,7 @@ export async function vectorSearchMovies(searchParams: {
genres: item.genres || [],
directors: item.directors || [],
cast: item.cast || [],
+ score: item.score,
// Add default values for fields not included in VectorSearchResult
fullplot: undefined,
released: undefined,
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/client/app/types/movie.ts b/mflix/client/app/types/movie.ts
index 360a87b..9f34f7d 100644
--- a/mflix/client/app/types/movie.ts
+++ b/mflix/client/app/types/movie.ts
@@ -5,7 +5,7 @@
/**
* Movie interface for type safety
- * Matches the Movie type from the Express backend
+ * Matches the Movie type from the backend
*/
export interface Movie {
_id: string;
@@ -51,11 +51,13 @@ export interface Movie {
};
metacritic?: number;
type?: string;
+ // Vector search score (only present in vector search results)
+ score?: number;
}
/**
* API Response interface for the movies endpoint
- * Matches the SuccessResponse type from the Express backend
+ * Matches the SuccessResponse type from the backend
*/
export interface MoviesApiResponse {
success: boolean;
diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/MongoConfig.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/MongoConfig.java
index 97ae854..815c248 100644
--- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/MongoConfig.java
+++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/MongoConfig.java
@@ -63,6 +63,8 @@ protected void configureClientSettings(MongoClientSettings.Builder builder) {
// Apply connection string and custom settings
builder.applyConnectionString(connectionString)
+ // Set application name
+ .applicationName("sample-app-java-mflix")
// Configure connection pool for optimal performance
.applyToConnectionPoolSettings(poolBuilder ->
poolBuilder.maxSize(100) // Maximum connections in pool
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..138444f 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;
@@ -631,39 +634,84 @@ public List searchMovies(MovieSearchRequest searchRequest) {
));
}
- // Add directors search if provided (using text operator with fuzzy matching)
+ // Add directors search if provided
+ // Use compound operator with "should" clauses to create a scoring hierarchy:
+ // 1. phrase match (highest score) - exact phrase in same array element
+ // 2. text match without fuzzy (high score) - all terms present, exact spelling
+ // 3. text match with fuzzy (lower score) - typo-tolerant fallback; update fuzzy settings as needed
+ // For more details, see: https://www.mongodb.com/docs/atlas/atlas-search/operators-collectors/text/
if (searchRequest.getDirectors() != null && !searchRequest.getDirectors().trim().isEmpty()) {
- searchPhrases.add(new Document("text", new Document()
- .append("query", searchRequest.getDirectors().trim())
- .append("path", Movie.Fields.DIRECTORS)
- .append("fuzzy", new Document()
- .append("maxEdits", 1)
- .append("prefixLength", 5)
- )
+ String directorsQuery = searchRequest.getDirectors().trim();
+ searchPhrases.add(new Document("compound", new Document()
+ .append("should", java.util.Arrays.asList(
+ // Highest score: exact phrase match
+ new Document("phrase", new Document()
+ .append("query", directorsQuery)
+ .append("path", Movie.Fields.DIRECTORS)),
+ // High score: exact text match (all terms, no fuzzy)
+ new Document("text", new Document()
+ .append("query", directorsQuery)
+ .append("path", Movie.Fields.DIRECTORS)
+ .append("matchCriteria", "all")),
+ // Lower score: fuzzy match (typo tolerance)
+ new Document("text", new Document()
+ .append("query", directorsQuery)
+ .append("path", Movie.Fields.DIRECTORS)
+ .append("matchCriteria", "all")
+ // Fuzzy settings: allow up to 1 edit, require first 2 characters to match
+ .append("fuzzy", new Document()
+ .append("maxEdits", 1)
+ .append("prefixLength", 2)))
+ ))
+ .append("minimumShouldMatch", 1)
));
}
- // Add writers search if provided (using text operator with fuzzy matching)
+ // Add writers search if provided (see directors comments for compound scoring hierarchy)
if (searchRequest.getWriters() != null && !searchRequest.getWriters().trim().isEmpty()) {
- searchPhrases.add(new Document("text", new Document()
- .append("query", searchRequest.getWriters().trim())
- .append("path", Movie.Fields.WRITERS)
- .append("fuzzy", new Document()
- .append("maxEdits", 1)
- .append("prefixLength", 5)
- )
+ String writersQuery = searchRequest.getWriters().trim();
+ searchPhrases.add(new Document("compound", new Document()
+ .append("should", java.util.Arrays.asList(
+ new Document("phrase", new Document()
+ .append("query", writersQuery)
+ .append("path", Movie.Fields.WRITERS)),
+ new Document("text", new Document()
+ .append("query", writersQuery)
+ .append("path", Movie.Fields.WRITERS)
+ .append("matchCriteria", "all")),
+ new Document("text", new Document()
+ .append("query", writersQuery)
+ .append("path", Movie.Fields.WRITERS)
+ .append("matchCriteria", "all")
+ .append("fuzzy", new Document()
+ .append("maxEdits", 1)
+ .append("prefixLength", 2)))
+ ))
+ .append("minimumShouldMatch", 1)
));
}
- // Add cast search if provided (using text operator with fuzzy matching)
+ // Add cast search if provided (see directors comments for compound scoring hierarchy)
if (searchRequest.getCast() != null && !searchRequest.getCast().trim().isEmpty()) {
- searchPhrases.add(new Document("text", new Document()
- .append("query", searchRequest.getCast().trim())
- .append("path", Movie.Fields.CAST)
- .append("fuzzy", new Document()
- .append("maxEdits", 1)
- .append("prefixLength", 5)
- )
+ String castQuery = searchRequest.getCast().trim();
+ searchPhrases.add(new Document("compound", new Document()
+ .append("should", java.util.Arrays.asList(
+ new Document("phrase", new Document()
+ .append("query", castQuery)
+ .append("path", Movie.Fields.CAST)),
+ new Document("text", new Document()
+ .append("query", castQuery)
+ .append("path", Movie.Fields.CAST)
+ .append("matchCriteria", "all")),
+ new Document("text", new Document()
+ .append("query", castQuery)
+ .append("path", Movie.Fields.CAST)
+ .append("matchCriteria", "all")
+ .append("fuzzy", new Document()
+ .append("maxEdits", 1)
+ .append("prefixLength", 2)))
+ ))
+ .append("minimumShouldMatch", 1)
));
}
@@ -821,8 +869,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 +977,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 +1035,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/config/database.ts b/mflix/server/js-express/src/config/database.ts
index 44cce5d..09c5884 100644
--- a/mflix/server/js-express/src/config/database.ts
+++ b/mflix/server/js-express/src/config/database.ts
@@ -29,7 +29,10 @@ async function _connectToDatabase(): Promise {
try {
// Create new MongoDB client instance
- client = new MongoClient(uri);
+ client = new MongoClient(uri, {
+ // Set application name
+ appName: "sample-app-node-mflix",
+ });
// Connect to MongoDB
await client.connect();
diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts
index d913a09..0d107e5 100644
--- a/mflix/server/js-express/src/controllers/movieController.ts
+++ b/mflix/server/js-express/src/controllers/movieController.ts
@@ -578,32 +578,72 @@ export async function searchMovies(req: Request, res: Response): Promise {
});
}
+ // Use compound operator with "should" clauses to create a scoring hierarchy:
+ // 1. phrase match (highest score) - exact phrase in same array element
+ // 2. text match without fuzzy (high score) - all terms present, exact spelling
+ // 3. text match with fuzzy (lower score) - typo-tolerant fallback; update fuzzy settings as needed
+ // For more details, see: https://www.mongodb.com/docs/atlas/atlas-search/operators-collectors/text/
if (directors) {
searchPhrases.push({
- text: {
- query: directors,
- path: "directors",
- fuzzy: { maxEdits: 1, prefixLength: 5 },
+ compound: {
+ should: [
+ // Highest score: exact phrase match
+ { phrase: { query: directors, path: "directors" } },
+ // High score: exact text match (all terms, no fuzzy)
+ { text: { query: directors, path: "directors", matchCriteria: "all" } },
+ // Lower score: fuzzy match (typo tolerance)
+ {
+ text: {
+ query: directors,
+ path: "directors",
+ matchCriteria: "all",
+ fuzzy: { maxEdits: 1, prefixLength: 2 }, // Allow up to 1 edit, require first 2 characters to match
+ },
+ },
+ ],
+ minimumShouldMatch: 1,
},
});
}
if (writers) {
+ // See comments above regarding compound scoring hierarchy.
searchPhrases.push({
- text: {
- query: writers,
- path: "writers",
- fuzzy: { maxEdits: 1, prefixLength: 5 },
+ compound: {
+ should: [
+ { phrase: { query: writers, path: "writers" } },
+ { text: { query: writers, path: "writers", matchCriteria: "all" } },
+ {
+ text: {
+ query: writers,
+ path: "writers",
+ matchCriteria: "all",
+ fuzzy: { maxEdits: 1, prefixLength: 2 },
+ },
+ },
+ ],
+ minimumShouldMatch: 1,
},
});
}
if (cast) {
+ // See comments above regarding compound scoring hierarchy.
searchPhrases.push({
- text: {
- query: cast,
- path: "cast",
- fuzzy: { maxEdits: 1, prefixLength: 5 },
+ compound: {
+ should: [
+ { phrase: { query: cast, path: "cast" } },
+ { text: { query: cast, path: "cast", matchCriteria: "all" } },
+ {
+ text: {
+ query: cast,
+ path: "cast",
+ matchCriteria: "all",
+ fuzzy: { maxEdits: 1, prefixLength: 2 },
+ },
+ },
+ ],
+ minimumShouldMatch: 1,
},
});
}
@@ -835,6 +875,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 +1287,36 @@ async function generateVoyageEmbedding(text: string, apiKey: string): Promise;
+ minimumShouldMatch?: number;
+ };
}
/**
diff --git a/mflix/server/js-express/tests/controllers/movieController.test.ts b/mflix/server/js-express/tests/controllers/movieController.test.ts
index e0928e9..f6d5b2e 100644
--- a/mflix/server/js-express/tests/controllers/movieController.test.ts
+++ b/mflix/server/js-express/tests/controllers/movieController.test.ts
@@ -880,7 +880,7 @@ describe("Movie Controller Tests", () => {
);
});
- 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 9d2290e..99913ae 100644
--- a/mflix/server/python-fastapi/main.py
+++ b/mflix/server/python-fastapi/main.py
@@ -1,9 +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.utils.errorHandler import register_error_handlers
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
@@ -15,8 +17,9 @@
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Create search indexes
- await ensure_search_index()
- await vector_search_index()
+ await ensure_mongodb_search_index()
+ await ensure_vector_search_index()
+ await ensure_standard_index()
# Print server information
print(f"\n{'='*60}")
@@ -30,10 +33,9 @@ async def lifespan(app: FastAPI):
# Add any cleanup code here
-async def ensure_search_index():
+async def ensure_mongodb_search_index():
try:
movies_collection = db.get_collection("movies")
- comments_collection = db.get_collection("comments")
# Check and create search index for movies collection
result = await movies_collection.list_search_indexes()
@@ -71,7 +73,7 @@ async def ensure_search_index():
)
-async def vector_search_index():
+async def ensure_vector_search_index():
"""
Creates vector search index on application startup if it doesn't already exist.
This ensures the index is ready before any vector search requests are made.
@@ -114,9 +116,54 @@ async def vector_search_index():
f"and verify the 'embedded_movies' collection exists with the required embedding field."
)
+async def ensure_standard_index():
+ """
+ Creates a standard MongoDB index on the comments collection on application startup.
+ This improves performance for queries filtering by movie_id such as ReportingByComments().
+ """
+
+ try:
+ comments_collection = db.get_collection("comments")
+
+ existing_indexes_cursor = await comments_collection.list_indexes()
+ existing_indexes = [index async for index in existing_indexes_cursor]
+ index_names = [index.get("name") for index in existing_indexes]
+ standard_index_name = "movie_id_index"
+ if standard_index_name not in index_names:
+ await comments_collection.create_index([("movie_id", 1)], name=standard_index_name)
+
+ except Exception as e:
+ print(f"Failed to create standard index on 'comments' collection: {str(e)}. ")
+ print(f"Performance may be degraded. Please check your MongoDB configuration.")
+
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(
@@ -127,6 +174,5 @@ async def vector_search_index():
allow_headers=["*"],
)
-register_error_handlers(app)
app.include_router(movies.router, prefix="/api/movies", tags=["movies"])
diff --git a/mflix/server/python-fastapi/src/database/mongo_client.py b/mflix/server/python-fastapi/src/database/mongo_client.py
index 0ff350e..37ab816 100644
--- a/mflix/server/python-fastapi/src/database/mongo_client.py
+++ b/mflix/server/python-fastapi/src/database/mongo_client.py
@@ -5,7 +5,10 @@
load_dotenv()
-client = AsyncMongoClient(os.getenv("MONGO_URI"))
+client = AsyncMongoClient(os.getenv("MONGO_URI"),
+ # Set application name
+ appname="sample-app-python-mflix")
+
db = client[os.getenv("MONGO_DB")]
voyage_api_key = os.getenv("VOYAGE_API_KEY")
@@ -18,4 +21,6 @@ def get_collection(name:str):
def voyage_ai_available():
"""Check if Voyage API Key is available and valid."""
api_key = os.getenv("VOYAGE_API_KEY")
+ if api_key is None or api_key =="your_voyage_api_key":
+ return None
return api_key is not None and api_key.strip() != ""
\ No newline at end of file
diff --git a/mflix/server/python-fastapi/src/models/models.py b/mflix/server/python-fastapi/src/models/models.py
index f9494c7..01c1489 100644
--- a/mflix/server/python-fastapi/src/models/models.py
+++ b/mflix/server/python-fastapi/src/models/models.py
@@ -127,22 +127,9 @@ class SuccessResponse(BaseModel, Generic[T]):
timestamp: str
pagination: Optional[Pagination] = None
-
-class ErrorDetails(BaseModel):
- message: str
- code: Optional[str]
- details: Optional[Any] = None
-
class BatchUpdateRequest(BaseModel):
filter: MovieFilter
update: UpdateMovieRequest
class BatchDeleteRequest(BaseModel):
filter: MovieFilter
-
-class ErrorResponse(BaseModel):
- success: bool = False
- message: str
- error: ErrorDetails
- timestamp: str
-
\ No newline at end of file
diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py
index 4cc2154..32f04a3 100644
--- a/mflix/server/python-fastapi/src/routers/movies.py
+++ b/mflix/server/python-fastapi/src/routers/movies.py
@@ -1,13 +1,17 @@
-from fastapi import APIRouter, Query, Path, Body
+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
-from src.utils.errorHandler import create_success_response, create_error_response
-from bson import ObjectId
+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
'''
@@ -105,15 +109,15 @@
@router.get(
"/search",
response_model=SuccessResponse[SearchMoviesResponse],
- status_code=200,
+ status_code = 200,
summary="Search movies using MongoDB Search."
)
async def search_movies(
- plot: str = Query(default=None),
- fullplot: str = Query(default=None),
- directors: str = Query(default=None),
- writers: str = Query(default=None),
- cast: str = Query(default=None),
+ plot: Optional[str] = None,
+ fullplot: Optional[str] = None,
+ directors: Optional[str] = None,
+ writers: Optional[str] = None,
+ cast: Optional[str] = None,
limit:int = Query(default=20, ge=1, le=100),
skip:int = Query(default=0, ge=0),
search_operator: str = Query(default="must", alias="searchOperator")
@@ -123,17 +127,17 @@ async def search_movies(
# Validate the search_operator parameter to ensure it's a valid compound operator
valid_operators = {"must", "should", "mustNot", "filter"}
+
if search_operator not in valid_operators:
- return create_error_response(
- message=f"Invalid search_operator '{search_operator}'. The search_operator must be one of {valid_operators}.",
- code="INVALID_SEARCH_OPERATOR",
- details=None
- )
+ raise HTTPException(
+ status_code = 400,
+ detail=f"Invalid search operator '{search_operator}'. The search operator must be one of {valid_operators}."
+ )
# Build the search_phrases list based on which fields were provided by the user.
# Each phrase becomes a separate clause in the MongoDB Search compound query.
- if plot:
+ if plot is not None:
search_phrases.append({
# The phrase operator performs an exact phrase match on the specified field. This is useful for searching for specific phrases within text fields.
# The text operator is more flexible and allows for fuzzy matching, making it suitable for fields like names where typos may occur.
@@ -142,53 +146,66 @@ async def search_movies(
"path": "plot",
}
})
- if fullplot:
+ if fullplot is not None:
search_phrases.append({
"phrase": {
"query": fullplot,
"path": "fullplot",
}
})
- if directors:
- # The "fuzzy" option enables typo-tolerant (fuzzy) search within MongoDB Search.
- # - maxEdits: The maximum number of single-character edits (insertions, deletions, or substitutions)
- # allowed when matching the search term to indexed terms. (Range: 1-2; higher = more tolerant)
- # - prefixLength: The number of initial characters that must exactly match before fuzzy matching is applied.
- # (Higher values make the search stricter and faster.)
+ if directors is not None:
+ # Use compound operator with "should" clauses to create a scoring hierarchy:
+ # 1. phrase match (highest score) - exact phrase in same array element
+ # 2. text match without fuzzy (high score) - all terms present, exact spelling
+ # 3. text match with fuzzy (lower score) - typo-tolerant fallback; update fuzzy settings as needed
# For more details, see: https://www.mongodb.com/docs/atlas/atlas-search/operators-collectors/text/
-
search_phrases.append({
- "text": {
- "query": directors,
- "path": "directors",
- "fuzzy":{"maxEdits":1, "prefixLength":5}
-
+ "compound": {
+ "should": [
+ # Highest score: exact phrase match
+ {"phrase": {"query": directors, "path": "directors"}},
+ # High score: exact text match (all terms, no fuzzy)
+ {"text": {"query": directors, "path": "directors", "matchCriteria": "all"}},
+ # Lower score: fuzzy match (typo tolerance)
+ {"text": {"query": directors, "path": "directors", "matchCriteria": "all",
+ "fuzzy": {"maxEdits": 1, "prefixLength": 2}}} # Allow up to 1 edit, require first 2 characters to match
+ ],
+ "minimumShouldMatch": 1
}
})
- if writers:
- # See comments above regarding fuzzy search options.
+
+ if writers is not None:
+ # See comments above regarding compound scoring hierarchy.
search_phrases.append({
- "text": {
- "query": writers,
- "path": "writers",
- "fuzzy":{"maxEdits":1, "prefixLength":5}
+ "compound": {
+ "should": [
+ {"phrase": {"query": writers, "path": "writers"}},
+ {"text": {"query": writers, "path": "writers", "matchCriteria": "all"}},
+ {"text": {"query": writers, "path": "writers", "matchCriteria": "all",
+ "fuzzy": {"maxEdits": 1, "prefixLength": 2}}}
+ ],
+ "minimumShouldMatch": 1
}
})
- if cast:
- # See comments above regarding fuzzy search options.
+
+ if cast is not None:
+ # See comments above regarding compound scoring hierarchy.
search_phrases.append({
- "text": {
- "query": cast,
- "path": "cast",
- "fuzzy":{"maxEdits":1, "prefixLength":5}
+ "compound": {
+ "should": [
+ {"phrase": {"query": cast, "path": "cast"}},
+ {"text": {"query": cast, "path": "cast", "matchCriteria": "all"}},
+ {"text": {"query": cast, "path": "cast", "matchCriteria": "all",
+ "fuzzy": {"maxEdits": 1, "prefixLength": 2}}}
+ ],
+ "minimumShouldMatch": 1
}
})
if not search_phrases:
- return create_error_response(
- message="At least one search parameter must be provided.",
- code="NO_SEARCH_PARAMETERS",
- details=None
+ raise HTTPException(
+ status_code = 400,
+ detail="At least one search parameter must be provided."
)
# Build the aggregation pipeline for MongoDB Search.
@@ -241,11 +258,11 @@ async def search_movies(
try:
results = await execute_aggregation(aggregation_pipeline)
except Exception as e:
- return create_error_response(
- message="An error occurred while performing the search.",
- code="DATABASE_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"An error occurred while performing the search: {str(e)}"
)
+
# Extract total count and movies from facet results with proper bounds checking
if not results or len(results) == 0:
@@ -318,20 +335,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():
- return create_error_response(
- message="Vector search unavailable",
- code="SERVICE_UNAVAILABLE",
- details="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()
-
# 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")
@@ -394,11 +411,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:
- return create_error_response(
- message="Vector search failed",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ # Log the error for debugging
+ print(f"Vector search error: {str(e)}")
+
+ # Handle generic errors
+ raise HTTPException(
+ status_code=500,
+ detail=f"Error performing vector search: {str(e)}"
)
"""
@@ -412,34 +438,32 @@ async def vector_search_movies(
@router.get("/{id}",
response_model=SuccessResponse[Movie],
- status_code=200,
+ status_code = 200,
summary="Retrieve a single movie by its ID.")
async def get_movie_by_id(id: str):
# Validate ObjectId format
try:
object_id = ObjectId(id)
- except InvalidId:
- return create_error_response(
- message="Invalid movie ID format",
- code="INTERNAL_SERVER_ERROR",
- details=f"The provided ID '{id}' is not a valid ObjectId"
+ except errors.InvalidId:
+ raise HTTPException(
+ status_code = 400,
+ detail=f"The provided ID '{id}' is not a valid ObjectId"
)
movies_collection = get_collection("movies")
try:
movie = await movies_collection.find_one({"_id": object_id})
except Exception as e:
- return create_error_response(
- message="Database error occurred",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred: {str(e)}"
)
+
if movie is None:
- return create_error_response(
- message="Movie not found",
- code="INTERNAL_SERVER_ERROR",
- details=f"No movie found with ID: {id}"
+ raise HTTPException(
+ status_code = 404,
+ detail=f"No movie found with ID: {id}"
)
movie["_id"] = str(movie["_id"]) # Convert ObjectId to string
@@ -468,7 +492,7 @@ async def get_movie_by_id(id: str):
@router.get("/",
response_model=SuccessResponse[List[Movie]],
- status_code=200,
+ status_code = 200,
summary="Retrieve a list of movies with optional filtering, sorting, and pagination.")
# Validate the query parameters using FastAPI's Query functionality.
async def get_all_movies(
@@ -511,25 +535,25 @@ async def get_all_movies(
try:
result = movies_collection.find(filter_dict).sort(sort).skip(skip).limit(limit)
except Exception as e:
- return create_error_response(
- message="An error occurred while fetching movies.",
- code="DATABASE_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"An error occurred while fetching movies. {str(e)}"
)
movies = []
async for movie in result:
- movie["_id"] = str(movie["_id"]) # Convert ObjectId to string
- # Ensure that the year field contains int value.
- if "year" in movie and not isinstance(movie["year"], int):
- cleaned_year = re.sub(r"\D", "", str(movie["year"]))
- try:
- movie["year"] = int(cleaned_year) if cleaned_year else None
- except ValueError:
- movie["year"] = None
-
- movies.append(movie)
+ if "title" in movie:
+ movie["_id"] = str(movie["_id"]) # Convert ObjectId to string
+ # Ensure that the year field contains int value.
+ if "year" in movie and not isinstance(movie["year"], int):
+ cleaned_year = re.sub(r"\D", "", str(movie["year"]))
+ try:
+ movie["year"] = int(cleaned_year) if cleaned_year else None
+ except ValueError:
+ movie["year"] = None
+
+ movies.append(movie)
# Return the results wrapped in a SuccessResponse
return create_success_response(movies, f"Found {len(movies)} movies.")
@@ -545,7 +569,7 @@ async def get_all_movies(
@router.post("/",
response_model=SuccessResponse[Movie],
- status_code=201,
+ status_code = 201,
summary="Creates a new movie in the database.")
async def create_movie(movie: CreateMovieRequest):
# Pydantic automatically validates the structure
@@ -555,35 +579,31 @@ async def create_movie(movie: CreateMovieRequest):
try:
result = await movies_collection.insert_one(movie_data)
except Exception as e:
- return create_error_response(
- message="Database error occurred",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred: {str(e)}"
)
# Verify that the document was created before querying it
if not result.acknowledged:
- return create_error_response(
- message="Failed to create movie",
- code="INTERNAL_SERVER_ERROR",
- details="The database did not acknowledge the insert operation"
+ raise HTTPException(
+ status_code = 500,
+ detail="Failed to create movie: The database did not acknowledge the insert operation"
)
try:
# Retrieve the created document to return complete data
created_movie = await movies_collection.find_one({"_id": result.inserted_id})
except Exception as e:
- return create_error_response(
- message="Database error occurred",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred: {str(e)}"
)
if created_movie is None:
- return create_error_response(
- message="Movie creation verification failed",
- code="INTERNAL_SERVER_ERROR",
- details="Movie was created but could not be retrieved for verification"
+ raise HTTPException(
+ status_code = 500,
+ detail="Movie was created but could not be retrieved for verification"
)
created_movie["_id"] = str(created_movie["_id"]) # Convert ObjectId to string
@@ -627,10 +647,9 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons
#Verify that the movies list is not empty
if not movies:
- return create_error_response(
- message="Request body must be a non-empty list of movies.",
- code="INVALID_INPUT",
- details=None
+ raise HTTPException(
+ status_code = 400,
+ detail="Request body must be a non-empty list of movies."
)
movies_dicts = []
@@ -650,10 +669,9 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons
f"Successfully created {len(result.inserted_ids)} movies."
)
except Exception as e:
- return create_error_response(
- message="Database error occurred",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred: {str(e)}"
)
"""
@@ -673,7 +691,7 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons
@router.patch(
"/{id}",
response_model=SuccessResponse[Movie],
- status_code=200,
+ status_code = 200,
summary="Update a single movie by its ID.")
async def update_movie(
movie_data: UpdateMovieRequest,
@@ -686,20 +704,18 @@ async def update_movie(
try:
movie_id = ObjectId(movie_id)
except Exception :
- return create_error_response(
- message="Invalid movie_id format.",
- code="INVALID_OBJECT_ID",
- details=str(movie_id)
+ raise HTTPException(
+ status_code = 400,
+ detail=f"Invalid movie_id format: {movie_id}"
)
update_dict = movie_data.model_dump(exclude_unset=True, exclude_none=True)
# Validate that the dict is not empty
if not update_dict:
- return create_error_response(
- message="No valid fields provided for update.",
- code="NO_UPDATE_DATA",
- details=None
+ raise HTTPException(
+ status_code = 400,
+ detail="No valid fields provided for update."
)
try:
@@ -708,17 +724,15 @@ async def update_movie(
{"$set":update_dict}
)
except Exception as e:
- return create_error_response(
- message="An error occurred while updating the movie.",
- code="DATABASE_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"An error occurred while updating the movie: {str(e)}"
)
if result.matched_count == 0:
- return create_error_response(
- message="No movie with that _id was found.",
- code="MOVIE_NOT_FOUND",
- details=str(movie_id)
+ raise HTTPException(
+ status_code = 404,
+ detail=f"No movie with that _id was found: {movie_id}"
)
updatedMovie = await movies_collection.find_one({"_id": movie_id})
@@ -740,7 +754,7 @@ async def update_movie(
@router.patch("/",
response_model=SuccessResponse[dict],
- status_code=200,
+ status_code = 200,
summary="Batch update movies matching the given filter."
)
async def update_movies_batch(
@@ -753,10 +767,9 @@ async def update_movies_batch(
update_data = request_body.get("update", {})
if not filter_data or not update_data:
- return create_error_response(
- message="Both filter and update objects are required",
- code="MISSING_REQUIRED_FIELDS",
- details=None
+ raise HTTPException(
+ status_code = 400,
+ detail="Both filter and update objects are required"
)
# Convert string IDs to ObjectIds if _id filter is present
@@ -766,19 +779,17 @@ async def update_movies_batch(
try:
filter_data["_id"]["$in"] = [ObjectId(id_str) for id_str in filter_data["_id"]["$in"]]
except Exception:
- return create_error_response(
- message="Invalid ObjectId format in filter",
- code="INVALID_OBJECT_ID",
- details=None
+ raise HTTPException(
+ status_code = 400,
+ detail="Invalid ObjectId format in filter",
)
try:
result = await movies_collection.update_many(filter_data, {"$set": update_data})
except Exception as e:
- return create_error_response(
- message="An error occurred while updating movies.",
- code="DATABASE_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"An error occurred while updating movies: {str(e)}"
)
return create_success_response({
@@ -799,16 +810,15 @@ async def update_movies_batch(
@router.delete("/{id}",
response_model=SuccessResponse[dict],
- status_code=200,
+ status_code = 200,
summary="Delete a single movie by its ID.")
async def delete_movie_by_id(id: str):
try:
object_id = ObjectId(id)
- except InvalidId:
- return create_error_response(
- message="Invalid movie ID format",
- code="INTERNAL_SERVER_ERROR",
- details=f"The provided ID '{id}' is not a valid ObjectId"
+ except errors.InvalidId:
+ raise HTTPException(
+ status_code = 400,
+ detail=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId"
)
movies_collection = get_collection("movies")
@@ -816,17 +826,15 @@ 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:
- return create_error_response(
- message="Database error occurred",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred: {str(e)}"
)
if result.deleted_count == 0:
- return create_error_response(
- message="Movie not found",
- code="INTERNAL_SERVER_ERROR",
- details=f"No movie found with ID: {id}"
+ raise HTTPException(
+ status_code = 404,
+ detail=f"No movie found with ID: {id}"
)
return create_success_response(
@@ -849,7 +857,7 @@ async def delete_movie_by_id(id: str):
@router.delete(
"/",
response_model=SuccessResponse[dict],
- status_code=200,
+ status_code = 200,
summary="Delete multiple movies matching the given filter."
)
async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse[dict]:
@@ -860,10 +868,9 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse
filter_data = request_body.get("filter", {})
if not filter_data:
- return create_error_response(
- message="Filter object is required and cannot be empty.",
- code="MISSING_FILTER",
- details=None
+ raise HTTPException(
+ status_code = 400,
+ detail="Filter object is required and cannot be empty."
)
# Convert string IDs to ObjectIds if _id filter is present
@@ -873,19 +880,17 @@ 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:
- return create_error_response(
- message="Invalid ObjectId format in filter",
- code="INVALID_OBJECT_ID",
- details=None
+ raise HTTPException(
+ status_code = 400,
+ detail="Invalid ObjectId format in filter."
)
try:
result = await movies_collection.delete_many(filter_data)
except Exception as e:
- return create_error_response(
- message="An error occurred while deleting movies.",
- code="DATABASE_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"An error occurred while deleting movies: {str(e)}"
)
return create_success_response(
@@ -905,16 +910,15 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse
@router.delete("/{id}/find-and-delete",
response_model=SuccessResponse[Movie],
- status_code=200,
+ status_code = 200,
summary="Find and delete a movie in a single operation.")
async def find_and_delete_movie(id: str):
try:
object_id = ObjectId(id)
- except InvalidId:
- return create_error_response(
- message="Invalid movie ID format",
- code="INTERNAL_SERVER_ERROR",
- details=f"The provided ID '{id}' is not a valid ObjectId"
+ except errors.InvalidId:
+ raise HTTPException(
+ status_code = 400,
+ detail=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId"
)
movies_collection = get_collection("movies")
@@ -924,17 +928,15 @@ 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:
- return create_error_response(
- message="Database error occurred",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred: {str(e)}"
)
if deleted_movie is None:
- return create_error_response(
- message="Movie not found",
- code="INTERNAL_SERVER_ERROR",
- details=f"No movie found with ID: {id}"
+ raise HTTPException(
+ status_code = 404,
+ detail=f"No movie found with ID: {id}"
)
deleted_movie["_id"] = str(deleted_movie["_id"]) # Convert ObjectId to string
@@ -953,7 +955,7 @@ async def find_and_delete_movie(id: str):
@router.get("/aggregations/reportingByComments",
response_model=SuccessResponse[List[dict]],
- status_code=200,
+ status_code = 200,
summary="Aggregate movies with their most recent comments.")
async def aggregate_movies_recent_commented(
limit: int = Query(default=10, ge=1, le=50),
@@ -984,10 +986,9 @@ async def aggregate_movies_recent_commented(
object_id = ObjectId(movie_id)
pipeline[0]["$match"]["_id"] = object_id
except Exception:
- return create_error_response(
- message="Invalid movie ID format",
- code="INTERNAL_SERVER_ERROR",
- details="The provided movie_id is not a valid ObjectId"
+ raise HTTPException(
+ status_code = 400,
+ detail="The provided movie_id is not a valid ObjectId"
)
# Add remaining pipeline stages
@@ -1074,10 +1075,9 @@ async def aggregate_movies_recent_commented(
try:
results = await execute_aggregation(pipeline)
except Exception as e:
- return create_error_response(
- message="Database error occurred during aggregation",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred during aggregation: {str(e)}"
)
# Convert ObjectId to string for response
@@ -1103,7 +1103,7 @@ async def aggregate_movies_recent_commented(
@router.get("/aggregations/reportingByYear",
response_model=SuccessResponse[List[dict]],
- status_code=200,
+ status_code = 200,
summary="Aggregate movies by year with average rating and movie count.")
async def aggregate_movies_by_year():
# Define aggregation pipeline to group movies by year with statistics
@@ -1205,10 +1205,9 @@ async def aggregate_movies_by_year():
try:
results = await execute_aggregation(pipeline)
except Exception as e:
- return create_error_response(
- message="Database error occurred during aggregation",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred during aggregation: {str(e)}"
)
return create_success_response(
@@ -1228,7 +1227,7 @@ async def aggregate_movies_by_year():
@router.get("/aggregations/reportingByDirectors",
response_model=SuccessResponse[List[dict]],
- status_code=200,
+ status_code = 200,
summary="Aggregate directors with the most movies and their statistics.")
async def aggregate_directors_most_movies(
limit: int = Query(default=20, ge=1, le=100)
@@ -1304,10 +1303,9 @@ async def aggregate_directors_most_movies(
try:
results = await execute_aggregation(pipeline)
except Exception as e:
- return create_error_response(
- message="Database error occurred during aggregation",
- code="INTERNAL_SERVER_ERROR",
- details=str(e)
+ raise HTTPException(
+ status_code = 500,
+ detail=f"Database error occurred during aggregation: {str(e)}"
)
return create_success_response(
@@ -1381,11 +1379,35 @@ def get_embedding(data, input_type = "document", client=None):
Returns:
Vector embeddings for the given input
+
+ Raises:
+ VoyageAuthError: If the API key is invalid (401)
+ VoyageAPIError: For other API errors
"""
- if client is None:
- client = voyageai.Client()
+ 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)
- embeddings = client.embed(
- data, model = model, output_dimension = outputDimension, input_type = input_type
- ).embeddings
- return embeddings[0]
diff --git a/mflix/server/python-fastapi/src/utils/errorHandler.py b/mflix/server/python-fastapi/src/utils/errorHandler.py
deleted file mode 100644
index 5e235ec..0000000
--- a/mflix/server/python-fastapi/src/utils/errorHandler.py
+++ /dev/null
@@ -1,109 +0,0 @@
-from fastapi import Request
-from fastapi.responses import JSONResponse
-from pymongo.errors import PyMongoError, DuplicateKeyError, WriteError
-from datetime import datetime, timezone
-from typing import Any, Optional
-from src.models.models import ErrorDetails, ErrorResponse, SuccessResponse, T
-
-'''
-Creates a standardized success response.
-
-
-Args:
- data (T): The data to include in the response.
- message (Optional[str]): An optional message to include.
-
-Returns:
- SuccessResponse[T]: A standardized success response object.
- '''
-
-def create_success_response(data:T, message: Optional[str] = None) -> SuccessResponse[T]:
- return SuccessResponse(
- message=message or "Operation completed successfully.",
- data=data,
- timestamp=datetime.now(timezone.utc).isoformat() + "Z",
-
- )
-
-'''
-Creates a standardized error response.
-
-Args:
- message (str): The error message.
- code (Optional[str]): An optional error code.
- details (Optional[Any]): Additional error details.
-
-Returns:
- ErrorResponse: A standardized error response object.
-
-'''
-
-def create_error_response(message: str, code: Optional[str]=None, details: Optional[Any]=None) -> ErrorResponse:
- return ErrorResponse(
- message=message,
- error=ErrorDetails(
- message=message,
- code=code,
- details=details
- ),
- timestamp=datetime.now(timezone.utc).isoformat() + "Z",
- )
-
-
-def parse_mongo_exception(exc: Exception) -> dict:
- if isinstance(exc, DuplicateKeyError):
- return{
- "message": "Duplicate key error occurred.",
- "code": "DUPLICATE_KEY_ERROR",
- "details": "A document with the same key already exists.",
- "statusCode":409
- }
-
- # This is stating that the data that you are trying to implement is the wrong shape
- # for the schema implemented in MongoDB.
- elif isinstance(exc, WriteError):
- return{
- "message": "Document validation failed.",
- "code": "WRITE_ERROR",
- "details": str(exc),
- "statusCode":400
- }
-
- elif isinstance(exc, PyMongoError):
- return {
- "message" : "A database error occurred.",
- "code": "DATABASE_ERROR",
- "details": str(exc),
- "statusCode":500
- }
- return {
- "message": "An unknown error occurred.",
- "code": "UNKNOWN_ERROR",
- "details": str(exc),
- "statusCode": 500
- }
-
-def register_error_handlers(app):
-
- @app.exception_handler(PyMongoError)
- async def mongo_exception_handler(request: Request, exc: PyMongoError):
- error_details = parse_mongo_exception(exc)
- return JSONResponse(
- status_code = error_details["statusCode"],
- content=create_error_response(
- message=error_details["message"],
- code=error_details["code"],
- details=error_details["details"]
- ).model_dump()
- )
-
- @app.exception_handler(Exception)
- async def generic_exception_handler(request: Request, exc: Exception):
- return JSONResponse(
- status_code=500,
- content=create_error_response(
- message=str(exc),
- code="INTERNAL_SERVER_ERROR",
- details=getattr(exc, 'detail', None) or getattr(exc, 'args', None)
- ).model_dump()
- )
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/src/utils/successResponse.py b/mflix/server/python-fastapi/src/utils/successResponse.py
new file mode 100644
index 0000000..1353dff
--- /dev/null
+++ b/mflix/server/python-fastapi/src/utils/successResponse.py
@@ -0,0 +1,24 @@
+from datetime import datetime, timezone
+from typing import Optional
+from src.models.models import SuccessResponse, T
+
+'''
+Creates a standardized success response.
+
+
+Args:
+ data (T): The data to include in the response.
+ message (Optional[str]): An optional message to include.
+
+Returns:
+ SuccessResponse[T]: A standardized success response object.
+ '''
+
+def create_success_response(data:T, message: Optional[str] = None) -> SuccessResponse[T]:
+ return SuccessResponse(
+ message=message or "Operation completed successfully.",
+ data=data,
+ timestamp=datetime.now(timezone.utc).isoformat() + "Z",
+
+ )
+
diff --git a/mflix/server/python-fastapi/tests/integration/conftest.py b/mflix/server/python-fastapi/tests/integration/conftest.py
index 6fb4e2d..80ed68d 100644
--- a/mflix/server/python-fastapi/tests/integration/conftest.py
+++ b/mflix/server/python-fastapi/tests/integration/conftest.py
@@ -56,13 +56,13 @@ def server():
# Start the server process
process = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", str(test_port)],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
cwd=server_python_dir
)
- # Wait for server to be ready (max 10 seconds)
- max_wait = 10
+ # Wait for server to be ready (max 30 seconds)
+ max_wait = 30
start_time = time.time()
while time.time() - start_time < max_wait:
if is_port_in_use(test_port):
@@ -209,5 +209,5 @@ async def test_batch_operation(multiple_test_movies):
if response_data.get("success") is False and "not found" in response_data.get("error", {}).get("message", "").lower():
# Movie was already deleted, which is fine
continue
- assert cleanup_response.status_code == 200, f"Failed to clean up movie {movie_id}"
+ assert cleanup_response.status_code in [200,404], f"Failed to clean up movie {movie_id}"
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 f018bbd..c9c9730 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
@@ -64,7 +64,7 @@ async def test_create_and_retrieve_movie(self, client, test_movie_data):
finally:
# Cleanup: Delete the test movie
delete_response = await client.delete(f"/api/movies/{movie_id}")
- assert delete_response.status_code == 200
+ assert delete_response.status_code in [200, 404]
@pytest.mark.asyncio
async def test_update_movie(self, client, created_movie):
@@ -123,12 +123,11 @@ async def test_delete_movie(self, client, test_movie_data):
assert delete_data["success"] is True
# Verify movie no longer exists
- # Note: The API returns 200 with INTERNAL_SERVER_ERROR code, not 404
get_response = await client.get(f"/api/movies/{movie_id}")
+ assert get_response.status_code == 404
error_data = get_response.json()
- assert error_data["success"] is False
- assert error_data["error"]["code"] == "INTERNAL_SERVER_ERROR"
- assert "not found" in error_data["error"]["message"].lower()
+ assert "detail" in error_data
+ assert "no movie found" in error_data["detail"].lower()
# No cleanup needed - movie already deleted
@@ -240,7 +239,8 @@ async def test_batch_create_movies(self, client):
finally:
# Cleanup: Delete all created movies
for movie_id in created_ids:
- await client.delete(f"/api/movies/{movie_id}")
+ delete_response = await client.delete(f"/api/movies/{movie_id}")
+ assert delete_response.status_code in [200, 404]
@pytest.mark.asyncio
async def test_batch_delete_movies(self, client, multiple_test_movies):
@@ -279,10 +279,10 @@ async def test_batch_delete_movies(self, client, multiple_test_movies):
# 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}")
- response_data = get_response.json()
- assert response_data["success"] is False
- assert response_data["error"]["code"] == "INTERNAL_SERVER_ERROR"
- assert "not found" in response_data["error"]["message"].lower()
+ 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()
# 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 b98937f..5256fab 100644
--- a/mflix/server/python-fastapi/tests/test_movie_routes.py
+++ b/mflix/server/python-fastapi/tests/test_movie_routes.py
@@ -9,8 +9,10 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from bson import ObjectId
+from fastapi import HTTPException
from src.models.models import CreateMovieRequest, UpdateMovieRequest
+from src.utils.exceptions import VoyageAuthError, VoyageAPIError
# Test constants
@@ -57,21 +59,23 @@ 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
- result = await get_movie_by_id(TEST_MOVIE_ID)
+ with pytest.raises(HTTPException) as e:
+ await get_movie_by_id(TEST_MOVIE_ID)
# Assertions
- assert result.success is False
- assert "not found" in result.message.lower()
+ assert e.value.status_code == 404
+ assert "no movie found" in str(e.value.detail).lower()
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
- result = await get_movie_by_id(INVALID_MOVIE_ID)
+ with pytest.raises(HTTPException) as e:
+ await get_movie_by_id(INVALID_MOVIE_ID)
# Assertions
- assert result.success is False
- assert "invalid" in result.message.lower()
+ assert e.value.status_code == 400
+ assert " not a valid" in str(e.value.detail).lower()
@patch('src.routers.movies.get_collection')
async def test_get_movie_by_id_database_error(self, mock_get_collection):
@@ -83,11 +87,12 @@ 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
- result = await get_movie_by_id(TEST_MOVIE_ID)
+ with pytest.raises(HTTPException) as e:
+ await get_movie_by_id(TEST_MOVIE_ID)
# Assertions
- assert result.success is False
- assert "error" in result.message.lower()
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail).lower()
@pytest.mark.unit
@@ -140,11 +145,12 @@ 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")
- result = await create_movie(movie_request)
+ with pytest.raises(HTTPException) as e:
+ await create_movie(movie_request)
# Assertions
- assert result.success is False
- assert "error" in result.message.lower()
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail).lower()
@pytest.mark.unit
@@ -194,22 +200,26 @@ async def test_update_movie_not_found(self, mock_get_collection):
# Create request
from src.routers.movies import update_movie
update_request = UpdateMovieRequest(title="Updated Movie")
- result = await update_movie(update_request, TEST_MOVIE_ID)
-
- # Assertions
- assert result.success is False
- assert "was found" in result.message.lower() or "not found" in result.message.lower()
+
+ 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())
async def test_update_movie_invalid_id(self):
"""Should return error when invalid ObjectId format is provided."""
# Create request
from src.routers.movies import update_movie
update_request = UpdateMovieRequest(title="Updated Movie")
- result = await update_movie(update_request, INVALID_MOVIE_ID)
+
+ with pytest.raises(HTTPException) as e:
+ await update_movie(update_request, INVALID_MOVIE_ID)
- # Assertions
- assert result.success is False
- assert "invalid" in result.message.lower()
+ # Assertions
+ assert e.value.status_code == 400
+ assert "invalid" in str(e.value.detail.lower())
@pytest.mark.unit
@@ -248,21 +258,23 @@ async def test_delete_movie_not_found(self, mock_get_collection):
# Call the route handler
from src.routers.movies import delete_movie_by_id
- result = await delete_movie_by_id(TEST_MOVIE_ID)
+ with pytest.raises(HTTPException) as e:
+ await delete_movie_by_id(TEST_MOVIE_ID)
- # Assertions
- assert result.success is False
- assert "not found" in result.message.lower()
+ # Assertions
+ assert e.value.status_code == 404
+ assert "no movie" in str(e.value.detail.lower())
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
- result = await delete_movie_by_id(INVALID_MOVIE_ID)
-
+ with pytest.raises(HTTPException) as e:
+ await delete_movie_by_id(INVALID_MOVIE_ID)
+
# Assertions
- assert result.success is False
- assert "invalid" in result.message.lower()
+ assert e.value.status_code == 400
+ assert "invalid movie id" in str(e.value.detail.lower())
@patch('src.routers.movies.get_collection')
async def test_delete_movie_database_error(self, mock_get_collection):
@@ -274,14 +286,12 @@ async def test_delete_movie_database_error(self, mock_get_collection):
# Call the route handler
from src.routers.movies import delete_movie_by_id
- result = await delete_movie_by_id(TEST_MOVIE_ID)
-
- # Assertions
- assert result.success is False
- assert "error" in result.message.lower()
-
-
+ with pytest.raises(HTTPException) as e:
+ await delete_movie_by_id(TEST_MOVIE_ID)
+ # Assertions
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail.lower())
@pytest.mark.unit
@pytest.mark.asyncio
@@ -384,11 +394,12 @@ async def test_get_all_movies_database_error(self, mock_get_collection):
# Call the route handler
from src.routers.movies import get_all_movies
- result = await get_all_movies()
+ with pytest.raises(HTTPException) as e:
+ await get_all_movies()
# Assertions
- assert result.success is False
- assert "error" in result.message.lower()
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail.lower())
@pytest.mark.unit
@@ -430,11 +441,12 @@ 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
- result = await create_movies_batch([])
+ with pytest.raises(HTTPException) as e:
+ await create_movies_batch([])
# Assertions
- assert result.success is False
- assert "empty" in result.message.lower()
+ assert e.value.status_code == 400
+ assert "empty" in str(e.value.detail.lower())
@patch('src.routers.movies.get_collection')
async def test_delete_movies_batch_success(self, mock_get_collection):
@@ -464,11 +476,12 @@ 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 = {}
- result = await delete_movies_batch(request_body)
+ with pytest.raises(HTTPException) as e:
+ await delete_movies_batch(request_body)
# Assertions
- assert result.success is False
- assert "filter" in result.message.lower()
+ assert e.value.status_code == 400
+ assert "filter" in e.value.detail.lower()
@@ -510,21 +523,23 @@ 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
- result = await find_and_delete_movie(TEST_MOVIE_ID)
+ with pytest.raises(HTTPException) as e:
+ await find_and_delete_movie(TEST_MOVIE_ID)
# Assertions
- assert result.success is False
- assert "not found" in result.message.lower()
+ assert e.value.status_code == 404
+ assert "no movie" in str(e.value.detail.lower())
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
- result = await find_and_delete_movie(INVALID_MOVIE_ID)
+ with pytest.raises(HTTPException) as e:
+ await find_and_delete_movie(INVALID_MOVIE_ID)
# Assertions
- assert result.success is False
- assert "invalid" in result.message.lower()
+ assert e.value.status_code == 400
+ assert "invalid" in str(e.value.detail.lower())
@pytest.mark.unit
@@ -565,11 +580,12 @@ 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"}}}
- result = await update_movies_batch(request_body)
+ with pytest.raises(HTTPException) as e:
+ await update_movies_batch(request_body)
# Assertions
- assert result.success is False
- assert "filter" in result.message.lower() or "required" in result.message.lower()
+ assert e.value.status_code == 400
+ assert "filter" in str(e.value.detail).lower()
@patch('src.routers.movies.get_collection')
async def test_update_movies_batch_missing_update(self, mock_get_collection):
@@ -579,11 +595,12 @@ 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}}
- result = await update_movies_batch(request_body)
+ with pytest.raises(HTTPException) as e:
+ await update_movies_batch(request_body)
# Assertions
- assert result.success is False
- assert "update" in result.message.lower() or "required" in result.message.lower()
+ assert e.value.status_code == 400
+ assert "update" in str(e.value.detail).lower()
@patch('src.routers.movies.get_collection')
async def test_update_movies_batch_no_matches(self, mock_get_collection):
@@ -683,20 +700,22 @@ 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
- result = await search_movies(search_operator="must")
+ with pytest.raises(HTTPException) as e:
+ await search_movies(search_operator="must")
# Assertions
- assert result.success is False
- assert result.error.code == "DATABASE_ERROR"
+ assert e.value.status_code == 400
+ assert "one search parameter" in str(e.value.detail).lower()
async def test_search_movies_invalid_operator(self):
"""Should return error for invalid search operator."""
from src.routers.movies import search_movies
- result = await search_movies(plot="test", search_operator="invalid")
+ with pytest.raises(HTTPException) as e:
+ await search_movies(plot="test", search_operator="invalid")
# Assertions
- assert result.success is False
- assert result.error.code == "INVALID_SEARCH_OPERATOR"
+ assert e.value.status_code == 400
+ assert "invalid search operator" in str(e.value.detail).lower()
@patch('src.routers.movies.execute_aggregation')
async def test_search_movies_database_error(self, mock_execute_aggregation):
@@ -706,11 +725,12 @@ async def test_search_movies_database_error(self, mock_execute_aggregation):
# Call the route handler
from src.routers.movies import search_movies
- result = await search_movies(plot="test", search_operator="must")
+ with pytest.raises(HTTPException) as e:
+ await search_movies(plot="test", search_operator="must")
# Assertions
- assert result.success is False
- assert result.error.code == "DATABASE_ERROR"
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail).lower()
@patch('src.routers.movies.execute_aggregation')
async def test_search_movies_empty_results(self, mock_execute_aggregation):
@@ -744,12 +764,21 @@ async def test_vector_search_unavailable(self, mock_voyage_available):
# Call the route handler
from src.routers.movies import vector_search_movies
- result = await vector_search_movies(q="action movie")
+ from fastapi.responses import JSONResponse
+
+ response = await vector_search_movies(q="action movie")
# Assertions
- assert result.success is False
- assert result.error.code == "SERVICE_UNAVAILABLE"
- assert "VOYAGE_API_KEY" in result.error.details
+ 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')
@patch('src.routers.movies.voyageai.Client')
@@ -798,11 +827,12 @@ 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
- result = await vector_search_movies(q="action movie")
+ with pytest.raises(HTTPException) as e:
+ await vector_search_movies(q="action movie")
# Assertions
- assert result.success is False
- assert result.error.code == "INTERNAL_SERVER_ERROR"
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail).lower()
@patch('src.routers.movies.voyage_ai_available')
@patch('src.routers.movies.voyageai.Client')
@@ -896,12 +926,12 @@ 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
- result = await aggregate_movies_recent_commented(movie_id="invalid_id")
+ with pytest.raises(HTTPException) as e:
+ await aggregate_movies_recent_commented(movie_id="invalid_id")
# Assertions
- assert result.success is False
- assert result.error.code == "INTERNAL_SERVER_ERROR"
- assert "ObjectId" in result.error.details
+ assert e.value.status_code == 400
+ assert "movie_id is not" in str(e.value.detail).lower()
@patch('src.routers.movies.execute_aggregation')
async def test_aggregate_movies_database_error(self, mock_execute_aggregation):
@@ -911,11 +941,12 @@ async def test_aggregate_movies_database_error(self, mock_execute_aggregation):
# Call the route handler
from src.routers.movies import aggregate_movies_recent_commented
- result = await aggregate_movies_recent_commented(limit=10, movie_id=None)
+ with pytest.raises(HTTPException) as e:
+ await aggregate_movies_recent_commented(limit=10, movie_id=None)
# Assertions
- assert result.success is False
- assert result.error.code == "INTERNAL_SERVER_ERROR"
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail).lower()
@patch('src.routers.movies.execute_aggregation')
async def test_aggregate_movies_empty_results(self, mock_execute_aggregation):
@@ -966,11 +997,12 @@ 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
- result = await aggregate_movies_by_year()
+ with pytest.raises(HTTPException) as e:
+ await aggregate_movies_by_year()
# Assertions
- assert result.success is False
- assert result.error.code == "INTERNAL_SERVER_ERROR"
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail).lower()
@patch('src.routers.movies.execute_aggregation')
async def test_aggregate_movies_by_year_empty_results(self, mock_execute_aggregation):
@@ -1039,11 +1071,12 @@ async def test_aggregate_directors_database_error(self, mock_execute_aggregation
# Call the route handler
from src.routers.movies import aggregate_directors_most_movies
- result = await aggregate_directors_most_movies()
+ with pytest.raises(HTTPException) as e:
+ await aggregate_directors_most_movies()
# Assertions
- assert result.success is False
- assert result.error.code == "INTERNAL_SERVER_ERROR"
+ assert e.value.status_code == 500
+ assert "error" in str(e.value.detail).lower()
@patch('src.routers.movies.execute_aggregation')
async def test_aggregate_directors_empty_results(self, mock_execute_aggregation):